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.
3 files changed,
+440
−0
internal/mail/memprovisioner.go+262 −0internal/mail/memprovisioner_test.go+111 −0internal/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 |
| +} |