carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
4 "context"
5 "errors"
6 "io"
7 "os"
8 "testing"
9
10 "github.com/carlosframework/platform/internal/appconfig"
11 "github.com/carlosframework/platform/internal/dnsrec"
12 "github.com/carlosframework/platform/internal/release"
13)
14
15const claimSoloDomain = "solo.com"
16
17// closureFixture: alpha and beta share shared.com. alpha also gets solo.com.
18// The call under test is alpha's EnsureDomain for solo.com, whose downstream
19// RefreshPolicies rewrites alpha's policy over BOTH of its domains, and the
20// backend boundary authorizes shared.com against beta as well.
21func newClosureFixture(t *testing.T, verifyShared bool) *claimDomainFixture {
22 t.Helper()
23 f := newClaimDomainFixture(t, "alpha", "beta")
24 if verifyShared {
25 if _, err := VerifyDomain(f.ctx, f.base, f.mem, testDeployment(), claimTestAccount, claimTestDomain, "eu-west-1", f.now); err != nil {
26 t.Fatal(err)
27 }
28 }
29 f.reset()
30 return f
31}
32
33func (f *claimDomainFixture) ensureSolo() error {
34 _, _, err := EnsureDomain(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha", claimSoloDomain, f.now)
35 return err
36}
37
38// Rev 5 P2: preflight admits every app a downstream RefreshPolicies needs
39// (the members of every verified domain the refreshed apps use) before any SES
40// or domain effect. On the original, only alpha was admitted: the call ran its
41// SES and domain effects and returned nil here (in the console the boundary
42// then refused beta's permit mid-walk and retained the gate).
43func TestClaimMailEnsureDomainPreflightsSharedDomainMembers(t *testing.T) {
44 f := newClosureFixture(t, true)
45 f.setAdmission("beta", "purging")
46 if err := f.ensureSolo(); !errors.Is(err, ErrClaimMailPending) {
47 t.Fatalf("EnsureDomain with a purging member of alpha's other shared domain: %v", err)
48 }
49 if n := f.prov.total(); n != 0 {
50 t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits)
51 }
52 if n := f.log.putsTo(DomainKey(claimTestAccount, claimSoloDomain)); n != 0 {
53 t.Fatalf("solo record puts=%d", n)
54 }
55 if _, _, err := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); err != nil {
56 t.Fatal(err)
57 }
58 if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); rec != nil {
59 t.Fatal("solo domain record exists after a refused call")
60 }
61 f.requireGateFree()
62
63 // Control: reopened, the same call goes through and does reach SES.
64 f.setAdmission("beta", "open")
65 f.reset()
66 if err := f.ensureSolo(); err != nil {
67 t.Fatalf("EnsureDomain after reopen: %v", err)
68 }
69 if f.prov.total() == 0 {
70 t.Fatal("control run never reached SES, so the zero count above proves nothing")
71 }
72}
73
74// The closure is exact, not a blanket: an UNVERIFIED shared domain grants
75// nothing to RefreshPolicies, so its members are not needed.
76func TestClaimMailEnsureDomainDoesNotAdmitUnverifiedSharedMembers(t *testing.T) {
77 f := newClosureFixture(t, false)
78 f.setAdmission("beta", "purging")
79 if err := f.ensureSolo(); err != nil {
80 t.Fatalf("EnsureDomain with beta purging on an unverified shared domain: %v", err)
81 }
82}
83
84// RemoveDomain's own RefreshPolicies and a bare RefreshPolicies carry the same
85// closure.
86func TestClaimMailRefreshAndRemovePreflightSharedDomainMembers(t *testing.T) {
87 f := newClosureFixture(t, true)
88 if err := f.ensureSolo(); err != nil {
89 t.Fatal(err)
90 }
91 f.setAdmission("beta", "purging")
92 f.reset()
93 if err := RefreshPolicies(f.ctx, f.marked, f.prov, claimTestAccount, []string{"alpha"}); !errors.Is(err, ErrClaimMailPending) {
94 t.Fatalf("RefreshPolicies: %v", err)
95 }
96 if _, err := RemoveDomain(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha", claimSoloDomain); !errors.Is(err, ErrClaimMailPending) {
97 t.Fatalf("RemoveDomain: %v", err)
98 }
99 if n := f.prov.total(); n != 0 {
100 t.Fatalf("SES calls before the refusals: %d %v", n, f.prov.hits)
101 }
102 if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); rec == nil {
103 t.Fatal("RemoveDomain detached the domain before refusing")
104 }
105}
106
107// A malformed domain record anywhere in the account makes the closure
108// incomplete, so the call refuses before any effect (as AppDomains does).
109func TestClaimMailClosureRefusesIncompleteDomainInventory(t *testing.T) {
110 f := newClosureFixture(t, true)
111 if _, err := f.base.Put(context.Background(), DomainKey(claimTestAccount, "broken.com"), []byte("{not json"), ""); err != nil {
112 t.Fatal(err)
113 }
114 f.reset()
115 if err := f.ensureSolo(); !errors.Is(err, ErrDomainInventory) {
116 t.Fatalf("EnsureDomain over a malformed record: %v", err)
117 }
118 if n := f.prov.total(); n != 0 {
119 t.Fatalf("SES calls: %d", n)
120 }
121}
122
123// Rev 5 P2 (C6): the enabled teardown refuses an incomplete claim or record
124// inventory before its first effect. On the original, ListDomainClaims logged
125// and skipped the unreadable claim and Teardown returned nil.
126func TestClaimMailTeardownRefusesIncompleteInventory(t *testing.T) {
127 for name, key := range map[string]string{
128 "claim": "email/domain-claims/orphan.example.com.json",
129 "record": DomainKey(claimTestAccount, "broken.example.com"),
130 } {
131 t.Run(name, func(t *testing.T) {
132 f := newClaimDomainFixture(t, "alpha")
133 if _, err := f.base.Put(f.ctx, key, []byte("{not json"), ""); err != nil {
134 t.Fatal(err)
135 }
136 f.reset()
137 err := Teardown(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha")
138 if !errors.Is(err, ErrDomainInventory) {
139 t.Fatalf("Teardown: %v", err)
140 }
141 if n := f.prov.total(); n != 0 {
142 t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits)
143 }
144 if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimTestDomain); rec == nil {
145 t.Fatal("teardown detached a domain before refusing")
146 }
147 })
148 }
149}
150
151// Flag off: an unmarked store keeps the lenient listings and the original
152// Teardown behaviour.
153func TestClaimMailTeardownUnmarkedStillSkipsBadClaims(t *testing.T) {
154 f := newClaimDomainFixture(t, "alpha")
155 if _, err := f.base.Put(f.ctx, "email/domain-claims/orphan.example.com.json", []byte("{not json"), ""); err != nil {
156 t.Fatal(err)
157 }
158 if err := Teardown(f.ctx, f.log, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha"); err != nil {
159 t.Fatalf("unmarked Teardown: %v", err)
160 }
161}
162
163// seedDomain writes domain for apps through the UNMARKED store (no gate, no
164// admission), points SES at a healthy verdict, and verifies it when asked.
165func (f *claimDomainFixture) seedDomain(domain string, verify bool, apps ...string) {
166 f.t.Helper()
167 for _, app := range apps {
168 if _, _, err := EnsureDomain(f.ctx, f.base, f.mem, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, app, domain, f.now); err != nil {
169 f.t.Fatalf("seed %s/%s: %v", domain, app, err)
170 }
171 }
172 f.mem.SetIdentityStatus("eu-west-1", domain, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"})
173 if verify {
174 if _, err := VerifyDomain(f.ctx, f.base, f.mem, testDeployment(), claimTestAccount, domain, "eu-west-1", f.now); err != nil {
175 f.t.Fatalf("verify %s: %v", domain, err)
176 }
177 }
178}
179
180// seedChain is the transitive shape: D1/A (pending), D2/A+B (verified) and
181// D3/B+C (verified when asked). A=alpha, B=beta, C=gamma. A reaches B through
182// D2, and B reaches C through D3, so the closure of D1 is {A, B, C}.
183func (f *claimDomainFixture) seedChain(verifyD3 bool) {
184 f.t.Helper()
185 f.seedDomain("d1.com", false, "alpha")
186 f.seedDomain("d2.com", true, "alpha", "beta")
187 f.seedDomain("d3.com", verifyD3, "beta", "gamma")
188 f.reset()
189}
190
191func (f *claimDomainFixture) verifyD1() (*DomainRecord, error) {
192 return VerifyDomain(f.ctx, f.marked, f.prov, testDeployment(), claimTestAccount, "d1.com", "eu-west-1", f.now)
193}
194
195func (f *claimDomainFixture) requireD1Pending() {
196 f.t.Helper()
197 rec, _, err := GetDomain(f.ctx, f.base, claimTestAccount, "d1.com")
198 if err != nil || rec == nil {
199 f.t.Fatalf("d1 record: %v %v", rec, err)
200 }
201 if st := rec.Regions["eu-west-1"].Status; st == StatusVerified {
202 f.t.Fatal("d1 verified despite the refusal")
203 }
204 if n := f.log.putsTo(DomainKey(claimTestAccount, "d1.com")); n != 0 {
205 f.t.Fatalf("d1 puts=%d", n)
206 }
207}
208
209// The real VerifyDomain, not EnsureDomain: D1/A is verified while D2/A+B and
210// D3/B+C are verified shared domains. A's policy rewrite authorizes D2 against
211// B, and B's D3 against C, so a closed B OR C must refuse before the first SES
212// call, the domain write or the config-set rebind, and release the shared
213// gate. The open case verifies and settles every admission.
214func TestClaimMailVerifyDomainRefusesClosedTransitiveMember(t *testing.T) {
215 for _, closed := range []string{"alpha", "beta", "gamma"} {
216 t.Run("closed "+closed, func(t *testing.T) {
217 f := newClaimDomainFixture(t)
218 f.seedChain(true)
219 f.setAdmission(closed, "purging")
220 if _, err := f.verifyD1(); !errors.Is(err, ErrClaimMailPending) {
221 t.Fatalf("VerifyDomain with %s closed: %v", closed, err)
222 }
223 if n := f.prov.total(); n != 0 {
224 t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits)
225 }
226 f.requireD1Pending()
227 f.requireGateFree()
228 if f.adm.released != 0 {
229 t.Fatalf("releases after a refused admission: %d", f.adm.released)
230 }
231 })
232 }
233
234 t.Run("open", func(t *testing.T) {
235 f := newClaimDomainFixture(t)
236 f.seedChain(true)
237 // Drift the binding so the verify rebinds and its nested
238 // RefreshPolicies runs under the preflighted permits.
239 if err := f.mem.SetIdentityConfigSet(f.ctx, "eu-west-1", "d1.com", ""); err != nil {
240 t.Fatal(err)
241 }
242 rec, err := f.verifyD1()
243 if err != nil {
244 t.Fatalf("VerifyDomain with every member open: %v", err)
245 }
246 if rec == nil || rec.Regions["eu-west-1"].Status != StatusVerified {
247 t.Fatalf("d1 not verified: %+v", rec)
248 }
249 if f.prov.total() == 0 {
250 t.Fatal("open run never reached SES, so the zero counts above prove nothing")
251 }
252 f.requireGateFree()
253 if f.adm.released != 1 {
254 t.Fatalf("releases = %d, want 1", f.adm.released)
255 }
256 })
257}
258
259// Scope: an UNVERIFIED shared domain grants nothing, so its members are not
260// needed. With D3 unverified, C is irrelevant but B (via verified D2) still is.
261func TestClaimMailVerifyDomainDoesNotAdmitUnverifiedDomainMembers(t *testing.T) {
262 f := newClaimDomainFixture(t)
263 f.seedChain(false)
264 f.setAdmission("gamma", "purging")
265 if _, err := f.verifyD1(); err != nil {
266 t.Fatalf("VerifyDomain with gamma closed on an unverified domain: %v", err)
267 }
268
269 g := newClaimDomainFixture(t)
270 g.seedChain(false)
271 g.setAdmission("beta", "purging")
272 if _, err := g.verifyD1(); !errors.Is(err, ErrClaimMailPending) {
273 t.Fatalf("VerifyDomain with beta closed on a verified shared domain: %v", err)
274 }
275 if n := g.prov.total(); n != 0 {
276 t.Fatalf("SES calls before the refusal: %d", n)
277 }
278}
279
280// Enable and CreateStandalone write the app's policy with EnsureUser over its
281// granted domains, and the boundary authorizes each against all its members,
282// so they carry the same closure: alpha -> D2 -> beta -> D3 -> gamma.
283func TestClaimMailEnableAndStandalonePreflightTransitiveMembers(t *testing.T) {
284 _, pubB64, err := appconfig.GenerateKey()
285 if err != nil {
286 t.Fatal(err)
287 }
288 pub, err := appconfig.ParsePublicKey(pubB64, "test")
289 if err != nil {
290 t.Fatal(err)
291 }
292 run := map[string]func(f *claimDomainFixture) error{
293 "enable": func(f *claimDomainFixture) error {
294 _, err := Enable(f.ctx, f.marked, f.prov, pub, testDeployment(), claimTestAccount, "alpha", f.now)
295 return err
296 },
297 "standalone": func(f *claimDomainFixture) error {
298 _, err := CreateStandalone(f.ctx, f.marked, f.prov, testDeployment(), claimTestAccount, "alpha", "smoke", "owner@example.test", f.now)
299 return err
300 },
301 }
302 for name, call := range run {
303 t.Run(name, func(t *testing.T) {
304 f := newClaimDomainFixture(t)
305 f.seedChain(true)
306 if _, err := Declare(f.ctx, f.base, claimTestAccount, "alpha", "ALPHA_SMTP", "noreply@d2.com", "owner@example.test", f.now); err != nil {
307 t.Fatal(err)
308 }
309 f.reset()
310 f.setAdmission("gamma", "purging")
311 if err := call(f); !errors.Is(err, ErrClaimMailPending) {
312 t.Fatalf("%s with a purging second-hop member: %v", name, err)
313 }
314 if n := f.prov.total(); n != 0 {
315 t.Fatalf("backend calls before the refusal: %d %v", n, f.prov.hits)
316 }
317 f.requireGateFree()
318
319 f.setAdmission("gamma", "open")
320 f.reset()
321 reopened := call(f)
322 if errors.Is(reopened, ErrClaimMailPending) {
323 t.Fatalf("%s after reopen still refused: %v", name, reopened)
324 }
325 f.prov.cmu.Lock()
326 ensured := f.prov.hits["EnsureUser"]
327 f.prov.cmu.Unlock()
328 if ensured == 0 {
329 t.Fatalf("%s never reached EnsureUser (err %v), so the zero count above proves nothing", name, reopened)
330 }
331 })
332 }
333}
334
335// dupListStore repeats every listed key, as a paged or eventually consistent
336// listing may.
337type dupListStore struct{ release.Store }
338
339func (d dupListStore) List(ctx context.Context, prefix string) ([]string, error) {
340 keys, err := d.Store.List(ctx, prefix)
341 return append(keys, keys...), err
342}
343
344func TestStrictInventoriesAppSchemaAndDuplicates(t *testing.T) {
345 f := newClaimDomainFixture(t, "alpha")
346 dup := dupListStore{f.base}
347 claims, err := StrictDomainClaims(f.ctx, dup)
348 if err != nil || len(claims) != 1 {
349 t.Fatalf("duplicate claim listing: %v %v", claims, err)
350 }
351 recs, err := StrictListDomains(f.ctx, dup, claimTestAccount)
352 if err != nil || len(recs) != 1 {
353 t.Fatalf("duplicate record listing: %v %v", recs, err)
354 }
355
356 const key = "email/domain-claims/leg.example.com.json"
357 cases := []struct {
358 body string
359 wantErr bool
360 }{
361 // Legacy: no schema field, no app attribution.
362 {`{"domain":"leg.example.com","account":"bab"}`, false},
363 {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"alpha"}`, false},
364 {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"Bad App!"}`, true},
365 {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"../escape"}`, true},
366 {`{"schema":-1,"domain":"leg.example.com","account":"bab"}`, true},
367 {`{"schema":1,"domain":"leg.example.com","account":"not an account"}`, true},
368 }
369 for _, tc := range cases {
370 if _, err := f.base.Put(f.ctx, key, []byte(tc.body), ""); err != nil {
371 t.Fatal(err)
372 }
373 _, err := StrictDomainClaims(f.ctx, f.base)
374 if tc.wantErr && !errors.Is(err, ErrDomainInventory) {
375 t.Fatalf("claim %s: err=%v, want ErrDomainInventory", tc.body, err)
376 }
377 if !tc.wantErr && err != nil {
378 t.Fatalf("claim %s: unexpected err %v", tc.body, err)
379 }
380 if err := f.base.Delete(f.ctx, key); err != nil {
381 t.Fatal(err)
382 }
383 }
384}
385
386func TestStrictDomainClaimsAndRecords(t *testing.T) {
387 f := newClaimDomainFixture(t, "alpha")
388 claims, err := StrictDomainClaims(f.ctx, f.base)
389 if err != nil || len(claims) != 1 || claims[0].Domain != claimTestDomain {
390 t.Fatalf("clean inventory: %v %v", claims, err)
391 }
392 recs, err := StrictListDomains(f.ctx, f.base, claimTestAccount)
393 if err != nil || len(recs) != 1 {
394 t.Fatalf("clean records: %v %v", recs, err)
395 }
396 for name, body := range map[string]string{
397 "malformed": "{not json",
398 "schema": `{"schema":2,"domain":"bad.example.com","account":"bab"}`,
399 "mismatch": `{"schema":1,"domain":"other.example.com","account":"bab"}`,
400 } {
401 if _, err := f.base.Put(f.ctx, "email/domain-claims/bad.example.com.json", []byte(body), ""); err != nil {
402 t.Fatal(err)
403 }
404 if _, err := StrictDomainClaims(f.ctx, f.base); !errors.Is(err, ErrDomainInventory) {
405 t.Fatalf("%s claim: %v", name, err)
406 }
407 if err := f.base.Delete(f.ctx, "email/domain-claims/bad.example.com.json"); err != nil {
408 t.Fatal(err)
409 }
410 }
411 // The lenient listing is unchanged.
412 if _, err := f.base.Put(f.ctx, "email/domain-claims/bad.example.com.json", []byte("{not json"), ""); err != nil {
413 t.Fatal(err)
414 }
415 if got, err := ListDomainClaims(f.ctx, f.base); err != nil || len(got) != 1 {
416 t.Fatalf("lenient listing: %v %v", got, err)
417 }
418}
419
420type strictInventoryFault struct {
421 release.Store
422 prefix, key string
423 getErr, listErr error
424}
425
426func (s strictInventoryFault) List(ctx context.Context, prefix string) ([]string, error) {
427 if prefix == s.prefix && s.listErr != nil {
428 return []string{s.key}, s.listErr
429 }
430 return s.Store.List(ctx, prefix)
431}
432func (s strictInventoryFault) Get(ctx context.Context, key string) ([]byte, string, error) {
433 if key == s.key && s.getErr != nil {
434 return nil, "", s.getErr
435 }
436 return s.Store.Get(ctx, key)
437}
438
439func TestStrictMailInventoriesRejectPartialListsAndFailedReads(t *testing.T) {
440 for _, kind := range []string{"claim", "record"} {
441 for _, fault := range []string{"partial-list", "failed-read", "disappeared-read"} {
442 t.Run(kind+"/"+fault, func(t *testing.T) {
443 f := newClaimDomainFixture(t, "alpha")
444 s := strictInventoryFault{Store: f.base, prefix: domainClaimsPrefix, key: domainClaimKey(claimTestDomain)}
445 if kind == "record" {
446 s.prefix = DomainsPrefix(claimTestAccount)
447 s.key = DomainKey(claimTestAccount, claimTestDomain)
448 }
449 switch fault {
450 case "partial-list":
451 s.listErr = io.ErrUnexpectedEOF
452 case "failed-read":
453 s.getErr = io.ErrUnexpectedEOF
454 case "disappeared-read":
455 s.getErr = os.ErrNotExist
456 }
457 if kind == "claim" {
458 got, err := StrictDomainClaims(f.ctx, s)
459 if err == nil || got != nil {
460 t.Fatalf("partial claim inventory used: %+v %v", got, err)
461 }
462 } else {
463 got, err := StrictListDomains(f.ctx, s, claimTestAccount)
464 if err == nil || got != nil {
465 t.Fatalf("partial record inventory used: %+v %v", got, err)
466 }
467 }
468 })
469 }
470 }
471}
472
473func TestStrictMailInventoryRefusesMalformedMembersAndRegions(t *testing.T) {
474 for name, body := range map[string]string{
475 "negative-schema": `{"schema":-1,"domain":"bad.example.com"}`,
476 "invalid-member": `{"schema":1,"domain":"bad.example.com","apps":["../other"]}`,
477 "null-region": `{"schema":1,"domain":"bad.example.com","regions":{"eu-west-1":null}}`,
478 } {
479 t.Run(name, func(t *testing.T) {
480 f := newClaimDomainFixture(t, "alpha")
481 if _, err := f.base.Put(f.ctx, DomainKey(claimTestAccount, "bad.example.com"), []byte(body), ""); err != nil {
482 t.Fatal(err)
483 }
484 if got, err := StrictListDomains(f.ctx, f.base, claimTestAccount); !errors.Is(err, ErrDomainInventory) || got != nil {
485 t.Fatalf("malformed inventory: %+v %v", got, err)
486 }
487 })
488 }
489}
490