| 1 | // The in-memory Provisioner for walk tests: no AWS, no network, |
| 2 | // deterministic ids. It models the three behaviours the walks actually |
| 3 | // depend on — stable DKIM tokens across re-ensures, region-independent |
| 4 | // identities, and one-shot failure injection — and nothing else. |
| 5 | package mail |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "fmt" |
| 10 | "sync" |
| 11 | ) |
| 12 | |
| 13 | // MemKey is one access key: its secret and whether it is active (the pause). |
| 14 | type MemKey struct { |
| 15 | Secret string |
| 16 | Active bool |
| 17 | } |
| 18 | |
| 19 | // MemUser is the fake's view of an IAM principal. |
| 20 | type MemUser struct { |
| 21 | ARN string |
| 22 | Policy string |
| 23 | Keys map[string]MemKey |
| 24 | Deleted bool |
| 25 | } |
| 26 | |
| 27 | // MemIdentity is one (region, domain) SES identity. |
| 28 | type MemIdentity struct { |
| 29 | ARN string |
| 30 | DKIMTokens []string |
| 31 | MailFrom string |
| 32 | Status Status |
| 33 | // ConfigSet is stamped by SetIdentityConfigSet, mirroring |
| 34 | // PutEmailIdentityConfigurationSetAttributes (C1, review follow-up). |
| 35 | // Empty until then — including right after EnsureIdentity, which never |
| 36 | // sets it itself. |
| 37 | ConfigSet string |
| 38 | } |
| 39 | |
| 40 | type MemProvisioner struct { |
| 41 | mu sync.Mutex |
| 42 | account string |
| 43 | nextKey int |
| 44 | nextTok int |
| 45 | |
| 46 | Tenants map[string]bool |
| 47 | ConfigSets map[string]string |
| 48 | // Identities is keyed "region|domain". |
| 49 | Identities map[string]*MemIdentity |
| 50 | Users map[string]*MemUser |
| 51 | // TenantResources is tenant -> resource ARN -> associated. An identity |
| 52 | // with an association cannot be deleted, mirroring SES, because a walk |
| 53 | // that tore down in the wrong order would pass against a permissive fake |
| 54 | // and fail on the first real teardown. |
| 55 | TenantResources map[string]map[string]bool |
| 56 | // FailNext, keyed by method name, is consumed one-shot. Consumption |
| 57 | // happens BEFORE any state change so a failed call leaves state |
| 58 | // untouched — a walk test needs the retry to start from a clean point. |
| 59 | FailNext map[string]error |
| 60 | // StatusOnEnsure is the Status a newly ensured identity gets. Default |
| 61 | // is all-PENDING, which is what a real SES identity looks like before |
| 62 | // DNS converges; a test that wants an instantly-verified domain sets |
| 63 | // this to a verified Status. |
| 64 | StatusOnEnsure Status |
| 65 | } |
| 66 | |
| 67 | func NewMemProvisioner(account string) *MemProvisioner { |
| 68 | return &MemProvisioner{ |
| 69 | account: account, |
| 70 | Tenants: map[string]bool{}, |
| 71 | ConfigSets: map[string]string{}, |
| 72 | Identities: map[string]*MemIdentity{}, |
| 73 | Users: map[string]*MemUser{}, |
| 74 | TenantResources: map[string]map[string]bool{}, |
| 75 | FailNext: map[string]error{}, |
| 76 | StatusOnEnsure: Status{ |
| 77 | DKIM: "PENDING", MailFrom: "PENDING", Sending: "PENDING", |
| 78 | }, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func (p *MemProvisioner) takeFailure(method string) error { |
| 83 | if err, ok := p.FailNext[method]; ok { |
| 84 | delete(p.FailNext, method) |
| 85 | return err |
| 86 | } |
| 87 | return nil |
| 88 | } |
| 89 | |
| 90 | func identityKey(region, domain string) string { return region + "|" + domain } |
| 91 | |
| 92 | func (p *MemProvisioner) EnsureTenant(_ context.Context, name string) error { |
| 93 | p.mu.Lock() |
| 94 | defer p.mu.Unlock() |
| 95 | if err := p.takeFailure("EnsureTenant"); err != nil { |
| 96 | return err |
| 97 | } |
| 98 | p.Tenants[name] = true |
| 99 | return nil |
| 100 | } |
| 101 | |
| 102 | func (p *MemProvisioner) EnsureConfigSet(_ context.Context, region, name, topic string) error { |
| 103 | p.mu.Lock() |
| 104 | defer p.mu.Unlock() |
| 105 | if err := p.takeFailure("EnsureConfigSet"); err != nil { |
| 106 | return err |
| 107 | } |
| 108 | p.ConfigSets[region+"|"+name] = topic |
| 109 | return nil |
| 110 | } |
| 111 | |
| 112 | // EnsureIdentity is idempotent AND token-stable: a second call returns the |
| 113 | // identity already there rather than minting new tokens, because new tokens |
| 114 | // would orphan every DKIM CNAME already published. With adopt false an |
| 115 | // existing identity is refused untouched, as the real backend does. |
| 116 | func (p *MemProvisioner) EnsureIdentity(_ context.Context, region, domain, mailFrom string, adopt bool) (Identity, error) { |
| 117 | p.mu.Lock() |
| 118 | defer p.mu.Unlock() |
| 119 | if err := p.takeFailure("EnsureIdentity"); err != nil { |
| 120 | return Identity{}, err |
| 121 | } |
| 122 | k := identityKey(region, domain) |
| 123 | if got, ok := p.Identities[k]; ok { |
| 124 | if !adopt { |
| 125 | return Identity{}, fmt.Errorf("mail: identity %q in %s: %w", domain, region, ErrIdentityExists) |
| 126 | } |
| 127 | got.MailFrom = mailFrom |
| 128 | return Identity{ARN: got.ARN, DKIMTokens: got.DKIMTokens}, nil |
| 129 | } |
| 130 | toks := make([]string, 0, 3) |
| 131 | for i := 0; i < 3; i++ { |
| 132 | p.nextTok++ |
| 133 | toks = append(toks, fmt.Sprintf("tok%s%d", region[:2], p.nextTok)) |
| 134 | } |
| 135 | id := &MemIdentity{ |
| 136 | ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain), |
| 137 | DKIMTokens: toks, |
| 138 | MailFrom: mailFrom, |
| 139 | Status: p.StatusOnEnsure, |
| 140 | } |
| 141 | p.Identities[k] = id |
| 142 | return Identity{ARN: id.ARN, DKIMTokens: id.DKIMTokens, Created: true}, nil |
| 143 | } |
| 144 | |
| 145 | // SetIdentityConfigSet models PutEmailIdentityConfigurationSetAttributes: |
| 146 | // it stamps ConfigSet on an EXISTING identity and errors if there is none — |
| 147 | // the real backend would 404 too (C1, review follow-up). |
| 148 | func (p *MemProvisioner) SetIdentityConfigSet(_ context.Context, region, domain, configSet string) error { |
| 149 | p.mu.Lock() |
| 150 | defer p.mu.Unlock() |
| 151 | if err := p.takeFailure("SetIdentityConfigSet"); err != nil { |
| 152 | return err |
| 153 | } |
| 154 | got, ok := p.Identities[identityKey(region, domain)] |
| 155 | if !ok { |
| 156 | return fmt.Errorf("mail: no identity for %s in %s to set a configuration set on", domain, region) |
| 157 | } |
| 158 | got.ConfigSet = configSet |
| 159 | return nil |
| 160 | } |
| 161 | |
| 162 | func (p *MemProvisioner) IdentityStatus(_ context.Context, region, domain string) (Status, error) { |
| 163 | p.mu.Lock() |
| 164 | defer p.mu.Unlock() |
| 165 | if err := p.takeFailure("IdentityStatus"); err != nil { |
| 166 | return Status{}, err |
| 167 | } |
| 168 | got, ok := p.Identities[identityKey(region, domain)] |
| 169 | if !ok { |
| 170 | // Matches the real backend: SES's GetEmailIdentity 404s on an |
| 171 | // unknown identity, and awsProvisioner.IdentityStatus turns that |
| 172 | // into an all-NOT_STARTED Status with a nil error rather than an |
| 173 | // error — an identity nobody has ensured yet is not a failure, it |
| 174 | // just hasn't started. The fake used to error here; that was a |
| 175 | // divergence a walk test could pass against while production, given |
| 176 | // the same input, would not error at all. |
| 177 | return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil |
| 178 | } |
| 179 | // ConfigSet comes off the identity, not off Status, because that is how |
| 180 | // the real backend answers: GetEmailIdentity returns the binding |
| 181 | // alongside the verification axes, and awsProvisioner reads both from |
| 182 | // the one call. Returning got.Status alone would make the fake report an |
| 183 | // empty binding forever, and a reconciliation test could pass against |
| 184 | // that while production saw something else — the exact divergence the |
| 185 | // NOT_STARTED branch above exists to avoid. |
| 186 | st := got.Status |
| 187 | st.ConfigSet = got.ConfigSet |
| 188 | return st, nil |
| 189 | } |
| 190 | |
| 191 | // SetIdentityStatus is a test seam: drive an identity to verified the way |
| 192 | // DNS convergence would. |
| 193 | func (p *MemProvisioner) SetIdentityStatus(region, domain string, st Status) { |
| 194 | p.mu.Lock() |
| 195 | defer p.mu.Unlock() |
| 196 | if got, ok := p.Identities[identityKey(region, domain)]; ok { |
| 197 | got.Status = st |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func (p *MemProvisioner) DeleteIdentity(_ context.Context, region, domain string) error { |
| 202 | p.mu.Lock() |
| 203 | defer p.mu.Unlock() |
| 204 | if err := p.takeFailure("DeleteIdentity"); err != nil { |
| 205 | return err |
| 206 | } |
| 207 | got, ok := p.Identities[identityKey(region, domain)] |
| 208 | if ok { |
| 209 | for tenant, resources := range p.TenantResources { |
| 210 | if resources[got.ARN] { |
| 211 | return fmt.Errorf("mail: identity %s is still associated with tenant %s", domain, tenant) |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | delete(p.Identities, identityKey(region, domain)) |
| 216 | return nil |
| 217 | } |
| 218 | |
| 219 | func (p *MemProvisioner) AssociateTenantResource(_ context.Context, tenant, resourceARN string) error { |
| 220 | p.mu.Lock() |
| 221 | defer p.mu.Unlock() |
| 222 | if err := p.takeFailure("AssociateTenantResource"); err != nil { |
| 223 | return err |
| 224 | } |
| 225 | if p.TenantResources[tenant] == nil { |
| 226 | p.TenantResources[tenant] = map[string]bool{} |
| 227 | } |
| 228 | p.TenantResources[tenant][resourceARN] = true |
| 229 | return nil |
| 230 | } |
| 231 | |
| 232 | func (p *MemProvisioner) DisassociateTenantResource(_ context.Context, tenant, resourceARN string) error { |
| 233 | p.mu.Lock() |
| 234 | defer p.mu.Unlock() |
| 235 | if err := p.takeFailure("DisassociateTenantResource"); err != nil { |
| 236 | return err |
| 237 | } |
| 238 | delete(p.TenantResources[tenant], resourceARN) |
| 239 | return nil |
| 240 | } |
| 241 | |
| 242 | func (p *MemProvisioner) EnsureUser(_ context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) { |
| 243 | p.mu.Lock() |
| 244 | defer p.mu.Unlock() |
| 245 | if err := p.takeFailure("EnsureUser"); err != nil { |
| 246 | return "", err |
| 247 | } |
| 248 | // The refusal is modelled because the real backend must refuse too: a |
| 249 | // principal with no policy is an unscoped sending user. |
| 250 | doc, err := SendPolicyJSON(identityARNs, configSetARNs, domains) |
| 251 | if err != nil { |
| 252 | return "", err |
| 253 | } |
| 254 | u, ok := p.Users[name] |
| 255 | if !ok { |
| 256 | u = &MemUser{ARN: fmt.Sprintf("arn:aws:iam::%s:user%s%s", p.account, path, name), Keys: map[string]MemKey{}} |
| 257 | p.Users[name] = u |
| 258 | } |
| 259 | u.Policy = doc |
| 260 | return u.ARN, nil |
| 261 | } |
| 262 | |
| 263 | func (p *MemProvisioner) MintKey(_ context.Context, user string) (string, string, error) { |
| 264 | p.mu.Lock() |
| 265 | defer p.mu.Unlock() |
| 266 | if err := p.takeFailure("MintKey"); err != nil { |
| 267 | return "", "", err |
| 268 | } |
| 269 | u, ok := p.Users[user] |
| 270 | if !ok { |
| 271 | return "", "", fmt.Errorf("mail: no user %q", user) |
| 272 | } |
| 273 | // IAM hard-caps every user at two access keys and returns |
| 274 | // LimitExceededException on a third CreateAccessKey — this is the real |
| 275 | // constraint, so the fake enforces it too. Without this, a rotation walk |
| 276 | // that tries to mint a third key (the delivered key, a fresh key mid- |
| 277 | // rotation, and a stray third from a bug) would pass against the fake |
| 278 | // and fail the first time it ran against production. |
| 279 | if len(u.Keys) >= 2 { |
| 280 | return "", "", fmt.Errorf("mail: user %q already has 2 access keys, IAM's per-user limit", user) |
| 281 | } |
| 282 | p.nextKey++ |
| 283 | id := fmt.Sprintf("AKIAFAKE%06d", p.nextKey) |
| 284 | secret := fmt.Sprintf("secret-%06d", p.nextKey) |
| 285 | u.Keys[id] = MemKey{Secret: secret, Active: true} |
| 286 | return id, secret, nil |
| 287 | } |
| 288 | |
| 289 | func (p *MemProvisioner) SetKeyActive(_ context.Context, user, keyID string, active bool) error { |
| 290 | p.mu.Lock() |
| 291 | defer p.mu.Unlock() |
| 292 | if err := p.takeFailure("SetKeyActive"); err != nil { |
| 293 | return err |
| 294 | } |
| 295 | u, ok := p.Users[user] |
| 296 | if !ok { |
| 297 | return nil // already gone is a success |
| 298 | } |
| 299 | k, ok := u.Keys[keyID] |
| 300 | if !ok { |
| 301 | return nil |
| 302 | } |
| 303 | k.Active = active |
| 304 | u.Keys[keyID] = k |
| 305 | return nil |
| 306 | } |
| 307 | |
| 308 | func (p *MemProvisioner) DeleteKey(_ context.Context, user, keyID string) error { |
| 309 | p.mu.Lock() |
| 310 | defer p.mu.Unlock() |
| 311 | if err := p.takeFailure("DeleteKey"); err != nil { |
| 312 | return err |
| 313 | } |
| 314 | if u, ok := p.Users[user]; ok { |
| 315 | delete(u.Keys, keyID) |
| 316 | } |
| 317 | return nil |
| 318 | } |
| 319 | |
| 320 | func (p *MemProvisioner) ReapKeys(_ context.Context, user string, keep []string) error { |
| 321 | p.mu.Lock() |
| 322 | defer p.mu.Unlock() |
| 323 | if err := p.takeFailure("ReapKeys"); err != nil { |
| 324 | return err |
| 325 | } |
| 326 | u, ok := p.Users[user] |
| 327 | if !ok { |
| 328 | return nil |
| 329 | } |
| 330 | keepSet := make(map[string]bool, len(keep)) |
| 331 | for _, id := range keep { |
| 332 | keepSet[id] = true |
| 333 | } |
| 334 | for id := range u.Keys { |
| 335 | if !keepSet[id] { |
| 336 | delete(u.Keys, id) |
| 337 | } |
| 338 | } |
| 339 | return nil |
| 340 | } |
| 341 | |
| 342 | func (p *MemProvisioner) DeleteUser(_ context.Context, user string) error { |
| 343 | p.mu.Lock() |
| 344 | defer p.mu.Unlock() |
| 345 | if err := p.takeFailure("DeleteUser"); err != nil { |
| 346 | return err |
| 347 | } |
| 348 | delete(p.Users, user) |
| 349 | return nil |
| 350 | } |
| 351 | |