carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
5package mail
6
7import (
8 "context"
9 "fmt"
10 "sync"
11)
12
13// MemKey is one access key: its secret and whether it is active (the pause).
14type MemKey struct {
15 Secret string
16 Active bool
17}
18
19// MemUser is the fake's view of an IAM principal.
20type 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.
28type 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
40type 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
67func 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
82func (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
90func identityKey(region, domain string) string { return region + "|" + domain }
91
92func (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
102func (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.
116func (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).
148func (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
162func (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.
193func (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
201func (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
219func (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
232func (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
242func (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
263func (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
289func (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
308func (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
320func (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
342func (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