// 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 // ConfigSet is stamped by SetIdentityConfigSet, mirroring // PutEmailIdentityConfigurationSetAttributes (C1, review follow-up). // Empty until then — including right after EnsureIdentity, which never // sets it itself. ConfigSet string } 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 // TenantResources is tenant -> resource ARN -> associated. An identity // with an association cannot be deleted, mirroring SES, because a walk // that tore down in the wrong order would pass against a permissive fake // and fail on the first real teardown. TenantResources map[string]map[string]bool // 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{}, TenantResources: map[string]map[string]bool{}, 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. With adopt false an // existing identity is refused untouched, as the real backend does. func (p *MemProvisioner) EnsureIdentity(_ context.Context, region, domain, mailFrom string, adopt bool) (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 { if !adopt { return Identity{}, fmt.Errorf("mail: identity %q in %s: %w", domain, region, ErrIdentityExists) } 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, Created: true}, nil } // SetIdentityConfigSet models PutEmailIdentityConfigurationSetAttributes: // it stamps ConfigSet on an EXISTING identity and errors if there is none — // the real backend would 404 too (C1, review follow-up). func (p *MemProvisioner) SetIdentityConfigSet(_ context.Context, region, domain, configSet string) error { p.mu.Lock() defer p.mu.Unlock() if err := p.takeFailure("SetIdentityConfigSet"); err != nil { return err } got, ok := p.Identities[identityKey(region, domain)] if !ok { return fmt.Errorf("mail: no identity for %s in %s to set a configuration set on", domain, region) } got.ConfigSet = configSet return 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 { // Matches the real backend: SES's GetEmailIdentity 404s on an // unknown identity, and awsProvisioner.IdentityStatus turns that // into an all-NOT_STARTED Status with a nil error rather than an // error — an identity nobody has ensured yet is not a failure, it // just hasn't started. The fake used to error here; that was a // divergence a walk test could pass against while production, given // the same input, would not error at all. return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil } // ConfigSet comes off the identity, not off Status, because that is how // the real backend answers: GetEmailIdentity returns the binding // alongside the verification axes, and awsProvisioner reads both from // the one call. Returning got.Status alone would make the fake report an // empty binding forever, and a reconciliation test could pass against // that while production saw something else — the exact divergence the // NOT_STARTED branch above exists to avoid. st := got.Status st.ConfigSet = got.ConfigSet return st, 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 } got, ok := p.Identities[identityKey(region, domain)] if ok { for tenant, resources := range p.TenantResources { if resources[got.ARN] { return fmt.Errorf("mail: identity %s is still associated with tenant %s", domain, tenant) } } } delete(p.Identities, identityKey(region, domain)) return nil } func (p *MemProvisioner) AssociateTenantResource(_ context.Context, tenant, resourceARN string) error { p.mu.Lock() defer p.mu.Unlock() if err := p.takeFailure("AssociateTenantResource"); err != nil { return err } if p.TenantResources[tenant] == nil { p.TenantResources[tenant] = map[string]bool{} } p.TenantResources[tenant][resourceARN] = true return nil } func (p *MemProvisioner) DisassociateTenantResource(_ context.Context, tenant, resourceARN string) error { p.mu.Lock() defer p.mu.Unlock() if err := p.takeFailure("DisassociateTenantResource"); err != nil { return err } delete(p.TenantResources[tenant], resourceARN) return nil } func (p *MemProvisioner) EnsureUser(_ context.Context, name, path string, identityARNs, configSetARNs, 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, configSetARNs, 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) } // IAM hard-caps every user at two access keys and returns // LimitExceededException on a third CreateAccessKey — this is the real // constraint, so the fake enforces it too. Without this, a rotation walk // that tries to mint a third key (the delivered key, a fresh key mid- // rotation, and a stray third from a bug) would pass against the fake // and fail the first time it ran against production. if len(u.Keys) >= 2 { return "", "", fmt.Errorf("mail: user %q already has 2 access keys, IAM's per-user limit", 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 }