carlos / platform Public

mail: the provisioner seam and its in-memory fake

Every method is idempotent because every walk re-runs after a half-failure.
The fake models the three behaviours the walks depend on: DKIM tokens stay
stable across re-ensures (fresh ones would orphan published DNS), identities
are region-independent, and failures inject one-shot before any state
change so a retry starts clean.
Paul Campbell c8d3d1455c1e9884f2e1230f9d570af7f7452b72 parent 6636786
3 files changed, +440 −0
  • internal/mail/memprovisioner.go +262 −0
  • internal/mail/memprovisioner_test.go +111 −0
  • internal/mail/provisioner.go +67 −0
diff --git a/internal/mail/memprovisioner.go b/internal/mail/memprovisioner.go
new file mode 100644
index 000000000..f4bb83ed1
--- /dev/null
+++ b/internal/mail/memprovisioner.go
@@ -0,0 +1,262 @@
+// The in-memory Provisioner for walk tests: no AWS, no network,
+// deterministic ids. It models the three behaviours the walks actually
+// depend on — stable DKIM tokens across re-ensures, region-independent
+// identities, and one-shot failure injection — and nothing else.
+package mail
+
+import (
+ "context"
+ "fmt"
+ "sync"
+)
+
+// MemKey is one access key: its secret and whether it is active (the pause).
+type MemKey struct {
+ Secret string
+ Active bool
+}
+
+// MemUser is the fake's view of an IAM principal.
+type MemUser struct {
+ ARN string
+ Policy string
+ Keys map[string]MemKey
+ Deleted bool
+}
+
+// MemIdentity is one (region, domain) SES identity.
+type MemIdentity struct {
+ ARN string
+ DKIMTokens []string
+ MailFrom string
+ Status Status
+}
+
+type MemProvisioner struct {
+ mu sync.Mutex
+ account string
+ nextKey int
+ nextTok int
+
+ Tenants map[string]bool
+ ConfigSets map[string]string
+ // Identities is keyed "region|domain".
+ Identities map[string]*MemIdentity
+ Users map[string]*MemUser
+ // FailNext, keyed by method name, is consumed one-shot. Consumption
+ // happens BEFORE any state change so a failed call leaves state
+ // untouched — a walk test needs the retry to start from a clean point.
+ FailNext map[string]error
+ // StatusOnEnsure is the Status a newly ensured identity gets. Default
+ // is all-PENDING, which is what a real SES identity looks like before
+ // DNS converges; a test that wants an instantly-verified domain sets
+ // this to a verified Status.
+ StatusOnEnsure Status
+}
+
+func NewMemProvisioner(account string) *MemProvisioner {
+ return &MemProvisioner{
+ account: account,
+ Tenants: map[string]bool{},
+ ConfigSets: map[string]string{},
+ Identities: map[string]*MemIdentity{},
+ Users: map[string]*MemUser{},
+ FailNext: map[string]error{},
+ StatusOnEnsure: Status{
+ DKIM: "PENDING", MailFrom: "PENDING", Sending: "PENDING",
+ },
+ }
+}
+
+func (p *MemProvisioner) takeFailure(method string) error {
+ if err, ok := p.FailNext[method]; ok {
+ delete(p.FailNext, method)
+ return err
+ }
+ return nil
+}
+
+func identityKey(region, domain string) string { return region + "|" + domain }
+
+func (p *MemProvisioner) EnsureTenant(_ context.Context, name string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("EnsureTenant"); err != nil {
+ return err
+ }
+ p.Tenants[name] = true
+ return nil
+}
+
+func (p *MemProvisioner) EnsureConfigSet(_ context.Context, region, name, topic string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("EnsureConfigSet"); err != nil {
+ return err
+ }
+ p.ConfigSets[region+"|"+name] = topic
+ return nil
+}
+
+// EnsureIdentity is idempotent AND token-stable: a second call returns the
+// identity already there rather than minting new tokens, because new tokens
+// would orphan every DKIM CNAME already published.
+func (p *MemProvisioner) EnsureIdentity(_ context.Context, region, domain, mailFrom string) (Identity, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("EnsureIdentity"); err != nil {
+ return Identity{}, err
+ }
+ k := identityKey(region, domain)
+ if got, ok := p.Identities[k]; ok {
+ got.MailFrom = mailFrom
+ return Identity{ARN: got.ARN, DKIMTokens: got.DKIMTokens}, nil
+ }
+ toks := make([]string, 0, 3)
+ for i := 0; i < 3; i++ {
+ p.nextTok++
+ toks = append(toks, fmt.Sprintf("tok%s%d", region[:2], p.nextTok))
+ }
+ id := &MemIdentity{
+ ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain),
+ DKIMTokens: toks,
+ MailFrom: mailFrom,
+ Status: p.StatusOnEnsure,
+ }
+ p.Identities[k] = id
+ return Identity{ARN: id.ARN, DKIMTokens: id.DKIMTokens}, nil
+}
+
+func (p *MemProvisioner) IdentityStatus(_ context.Context, region, domain string) (Status, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("IdentityStatus"); err != nil {
+ return Status{}, err
+ }
+ got, ok := p.Identities[identityKey(region, domain)]
+ if !ok {
+ return Status{}, fmt.Errorf("mail: no identity %s in %s", domain, region)
+ }
+ return got.Status, nil
+}
+
+// SetIdentityStatus is a test seam: drive an identity to verified the way
+// DNS convergence would.
+func (p *MemProvisioner) SetIdentityStatus(region, domain string, st Status) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if got, ok := p.Identities[identityKey(region, domain)]; ok {
+ got.Status = st
+ }
+}
+
+func (p *MemProvisioner) DeleteIdentity(_ context.Context, region, domain string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("DeleteIdentity"); err != nil {
+ return err
+ }
+ delete(p.Identities, identityKey(region, domain))
+ return nil
+}
+
+func (p *MemProvisioner) EnsureUser(_ context.Context, name, path string, identityARNs, domains []string) (string, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("EnsureUser"); err != nil {
+ return "", err
+ }
+ // The refusal is modelled because the real backend must refuse too: a
+ // principal with no policy is an unscoped sending user.
+ doc, err := SendPolicyJSON(identityARNs, domains)
+ if err != nil {
+ return "", err
+ }
+ u, ok := p.Users[name]
+ if !ok {
+ u = &MemUser{ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", p.account, path, name), Keys: map[string]MemKey{}}
+ p.Users[name] = u
+ }
+ u.Policy = doc
+ return u.ARN, nil
+}
+
+func (p *MemProvisioner) MintKey(_ context.Context, user string) (string, string, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("MintKey"); err != nil {
+ return "", "", err
+ }
+ u, ok := p.Users[user]
+ if !ok {
+ return "", "", fmt.Errorf("mail: no user %q", user)
+ }
+ p.nextKey++
+ id := fmt.Sprintf("AKIAFAKE%06d", p.nextKey)
+ secret := fmt.Sprintf("secret-%06d", p.nextKey)
+ u.Keys[id] = MemKey{Secret: secret, Active: true}
+ return id, secret, nil
+}
+
+func (p *MemProvisioner) SetKeyActive(_ context.Context, user, keyID string, active bool) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("SetKeyActive"); err != nil {
+ return err
+ }
+ u, ok := p.Users[user]
+ if !ok {
+ return nil // already gone is a success
+ }
+ k, ok := u.Keys[keyID]
+ if !ok {
+ return nil
+ }
+ k.Active = active
+ u.Keys[keyID] = k
+ return nil
+}
+
+func (p *MemProvisioner) DeleteKey(_ context.Context, user, keyID string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("DeleteKey"); err != nil {
+ return err
+ }
+ if u, ok := p.Users[user]; ok {
+ delete(u.Keys, keyID)
+ }
+ return nil
+}
+
+func (p *MemProvisioner) ReapKeys(_ context.Context, user string, keep []string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("ReapKeys"); err != nil {
+ return err
+ }
+ u, ok := p.Users[user]
+ if !ok {
+ return nil
+ }
+ keepSet := make(map[string]bool, len(keep))
+ for _, id := range keep {
+ keepSet[id] = true
+ }
+ for id := range u.Keys {
+ if !keepSet[id] {
+ delete(u.Keys, id)
+ }
+ }
+ return nil
+}
+
+func (p *MemProvisioner) DeleteUser(_ context.Context, user string) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if err := p.takeFailure("DeleteUser"); err != nil {
+ return err
+ }
+ delete(p.Users, user)
+ return nil
+}
diff --git a/internal/mail/memprovisioner_test.go b/internal/mail/memprovisioner_test.go
new file mode 100644
index 000000000..7bc936b6b
--- /dev/null
+++ b/internal/mail/memprovisioner_test.go
@@ -0,0 +1,111 @@
+package mail
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+// Idempotence is the contract, not a nicety: every walk re-runs after a
+// half-failure, so a second EnsureIdentity must be a success that returns
+// the SAME tokens — fresh tokens would orphan the DNS already published.
+func TestMemEnsureIdentityIsIdempotentAndTokenStable(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ first, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com")
+ if err != nil {
+ t.Fatalf("EnsureIdentity: %v", err)
+ }
+ if len(first.DKIMTokens) != 3 {
+ t.Fatalf("want 3 DKIM tokens, got %d", len(first.DKIMTokens))
+ }
+ second, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com")
+ if err != nil {
+ t.Fatalf("re-EnsureIdentity: %v", err)
+ }
+ if first.ARN != second.ARN || first.DKIMTokens[0] != second.DKIMTokens[0] {
+ t.Fatal("re-ensuring changed the identity — the published DNS would be orphaned")
+ }
+}
+
+func TestMemIdentitiesAreRegionScoped(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ eu, _ := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "mail-eu-west-1.x.com")
+ us, _ := p.EnsureIdentity(ctx, "us-east-1", "x.com", "mail-us-east-1.x.com")
+ if eu.ARN == us.ARN {
+ t.Fatal("one ARN served two regions")
+ }
+ if eu.DKIMTokens[0] == us.DKIMTokens[0] {
+ t.Fatal("two regions shared a DKIM token — they are independent identities")
+ }
+}
+
+func TestMemDeleteIsIdempotent(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ if err := p.DeleteIdentity(ctx, "eu-west-1", "absent.com"); err != nil {
+ t.Fatalf("deleting an absent identity = %v, want nil", err)
+ }
+ if err := p.DeleteUser(ctx, "absent-user"); err != nil {
+ t.Fatalf("deleting an absent user = %v, want nil", err)
+ }
+}
+
+// The pause is SetKeyActive, and the fake must model it so a walk test can
+// assert a paused credential really is inactive.
+func TestMemSetKeyActiveTogglesTheKey(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ if _, err := p.EnsureUser(ctx, "u", UserPath, []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, []string{"x.com"}); err != nil {
+ t.Fatalf("EnsureUser: %v", err)
+ }
+ id, _, err := p.MintKey(ctx, "u")
+ if err != nil {
+ t.Fatalf("MintKey: %v", err)
+ }
+ if !p.Users["u"].Keys[id].Active {
+ t.Fatal("a freshly minted key must be active")
+ }
+ if err := p.SetKeyActive(ctx, "u", id, false); err != nil {
+ t.Fatalf("SetKeyActive: %v", err)
+ }
+ if p.Users["u"].Keys[id].Active {
+ t.Fatal("SetKeyActive(false) did not deactivate the key")
+ }
+}
+
+// ReapKeys is what finds a key stranded by a crash between mint and record —
+// no record names it, so only list-and-delete can.
+func TestMemReapKeysKeepsOnlyTheNamedOnes(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ p.EnsureUser(ctx, "u", UserPath, []string{"arn:aws:ses:eu-west-1:1:identity/x.com"}, []string{"x.com"})
+ keep, _, _ := p.MintKey(ctx, "u")
+ stranded, _, _ := p.MintKey(ctx, "u")
+ if err := p.ReapKeys(ctx, "u", []string{keep}); err != nil {
+ t.Fatalf("ReapKeys: %v", err)
+ }
+ if _, ok := p.Users["u"].Keys[stranded]; ok {
+ t.Fatal("ReapKeys left a key the record does not name")
+ }
+ if _, ok := p.Users["u"].Keys[keep]; !ok {
+ t.Fatal("ReapKeys deleted a key it was told to keep")
+ }
+}
+
+// EnsureUser must refuse an ungranted principal rather than create one with
+// no policy — an unscoped sending user is the failure this package exists to
+// prevent.
+func TestMemEnsureUserRefusesAnEmptyGrant(t *testing.T) {
+ if _, err := NewMemProvisioner("1").EnsureUser(context.Background(), "u", UserPath, nil, nil); err == nil {
+ t.Fatal("EnsureUser with no ARNs returned success")
+ }
+}
+
+func TestMemFailNextIsOneShot(t *testing.T) {
+ ctx, p := context.Background(), NewMemProvisioner("1")
+ boom := errors.New("boom")
+ p.FailNext["EnsureIdentity"] = boom
+ if _, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "m.x.com"); !errors.Is(err, boom) {
+ t.Fatalf("first call = %v, want boom", err)
+ }
+ if _, err := p.EnsureIdentity(ctx, "eu-west-1", "x.com", "m.x.com"); err != nil {
+ t.Fatalf("second call = %v, want nil", err)
+ }
+}
diff --git a/internal/mail/provisioner.go b/internal/mail/provisioner.go
new file mode 100644
index 000000000..1889f3a27
--- /dev/null
+++ b/internal/mail/provisioner.go
@@ -0,0 +1,67 @@
+// The backend seam (spec §5): everything the console needs a backend to do,
+// and nothing an app ever calls.
+//
+// Every method is idempotent — "already exists" and "already gone" are both
+// successes — because every walk that drives one must be re-runnable after a
+// half-failure. That is not a style preference; it is the only reason a
+// crashed provisioning run can be repaired by running it again rather than
+// by hand.
+package mail
+
+import "context"
+
+// Identity is what EnsureIdentity answers: the ARN a policy names and the
+// DKIM tokens whose CNAMEs must be published. The tokens are STABLE for the
+// life of the identity — re-ensuring returns the same three, because fresh
+// ones would orphan DNS already published (and, for a custom domain, DNS
+// somebody else published by hand).
+type Identity struct {
+ ARN string
+ DKIMTokens []string
+}
+
+// Status is SES's own verdict on an identity. All three must read
+// "SUCCESS" before the identity is usable; a domain that is verified for
+// sending but whose MAIL FROM has not converged still sends, from the
+// wrong envelope domain, and fails DMARC alignment.
+type Status struct {
+ DKIM string
+ MailFrom string
+ Sending string
+}
+
+// Verified reports whether every axis has converged.
+func (s Status) Verified() bool {
+ return s.DKIM == "SUCCESS" && s.MailFrom == "SUCCESS" && s.Sending == "SUCCESS"
+}
+
+type Provisioner interface {
+ // EnsureTenant creates the account's SES tenant if absent. Additive:
+ // a backend without tenant support returns nil and the walks still
+ // isolate correctly, because isolation is the IAM policy (spec §4).
+ EnsureTenant(ctx context.Context, name string) error
+ // EnsureConfigSet creates the event destination that feeds bounce and
+ // complaint ingestion (spec §9).
+ EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error
+
+ // EnsureIdentity creates the domain identity, enables Easy DKIM and
+ // sets the custom MAIL FROM subdomain, all idempotently.
+ EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error)
+ IdentityStatus(ctx context.Context, region, domain string) (Status, error)
+ DeleteIdentity(ctx context.Context, region, domain string) error
+
+ // EnsureUser creates the principal if absent and writes its inline
+ // policy wholesale. It MUST refuse an empty grant rather than create a
+ // principal with no policy.
+ EnsureUser(ctx context.Context, name, path string, identityARNs, domains []string) (arn string, err error)
+ MintKey(ctx context.Context, user string) (id, secret string, err error)
+ // SetKeyActive is the pause (spec §9): instant, reversible, and it
+ // needs no policy rewrite.
+ SetKeyActive(ctx context.Context, user, keyID string, active bool) error
+ DeleteKey(ctx context.Context, user, keyID string) error
+ // ReapKeys deletes every access key of user NOT named in keep. A key
+ // minted before a failed delivery is recorded nowhere, so only
+ // list-and-delete can find it.
+ ReapKeys(ctx context.Context, user string, keep []string) error
+ DeleteUser(ctx context.Context, user string) error
+}