package mail import ( "context" "errors" "io" "os" "testing" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/dnsrec" "github.com/carlosframework/platform/internal/release" ) const claimSoloDomain = "solo.com" // closureFixture: alpha and beta share shared.com. alpha also gets solo.com. // The call under test is alpha's EnsureDomain for solo.com, whose downstream // RefreshPolicies rewrites alpha's policy over BOTH of its domains, and the // backend boundary authorizes shared.com against beta as well. func newClosureFixture(t *testing.T, verifyShared bool) *claimDomainFixture { t.Helper() f := newClaimDomainFixture(t, "alpha", "beta") if verifyShared { if _, err := VerifyDomain(f.ctx, f.base, f.mem, testDeployment(), claimTestAccount, claimTestDomain, "eu-west-1", f.now); err != nil { t.Fatal(err) } } f.reset() return f } func (f *claimDomainFixture) ensureSolo() error { _, _, err := EnsureDomain(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha", claimSoloDomain, f.now) return err } // Rev 5 P2: preflight admits every app a downstream RefreshPolicies needs // (the members of every verified domain the refreshed apps use) before any SES // or domain effect. On the original, only alpha was admitted: the call ran its // SES and domain effects and returned nil here (in the console the boundary // then refused beta's permit mid-walk and retained the gate). func TestClaimMailEnsureDomainPreflightsSharedDomainMembers(t *testing.T) { f := newClosureFixture(t, true) f.setAdmission("beta", "purging") if err := f.ensureSolo(); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("EnsureDomain with a purging member of alpha's other shared domain: %v", err) } if n := f.prov.total(); n != 0 { t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits) } if n := f.log.putsTo(DomainKey(claimTestAccount, claimSoloDomain)); n != 0 { t.Fatalf("solo record puts=%d", n) } if _, _, err := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); err != nil { t.Fatal(err) } if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); rec != nil { t.Fatal("solo domain record exists after a refused call") } f.requireGateFree() // Control: reopened, the same call goes through and does reach SES. f.setAdmission("beta", "open") f.reset() if err := f.ensureSolo(); err != nil { t.Fatalf("EnsureDomain after reopen: %v", err) } if f.prov.total() == 0 { t.Fatal("control run never reached SES, so the zero count above proves nothing") } } // The closure is exact, not a blanket: an UNVERIFIED shared domain grants // nothing to RefreshPolicies, so its members are not needed. func TestClaimMailEnsureDomainDoesNotAdmitUnverifiedSharedMembers(t *testing.T) { f := newClosureFixture(t, false) f.setAdmission("beta", "purging") if err := f.ensureSolo(); err != nil { t.Fatalf("EnsureDomain with beta purging on an unverified shared domain: %v", err) } } // RemoveDomain's own RefreshPolicies and a bare RefreshPolicies carry the same // closure. func TestClaimMailRefreshAndRemovePreflightSharedDomainMembers(t *testing.T) { f := newClosureFixture(t, true) if err := f.ensureSolo(); err != nil { t.Fatal(err) } f.setAdmission("beta", "purging") f.reset() if err := RefreshPolicies(f.ctx, f.marked, f.prov, claimTestAccount, []string{"alpha"}); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("RefreshPolicies: %v", err) } if _, err := RemoveDomain(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha", claimSoloDomain); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("RemoveDomain: %v", err) } if n := f.prov.total(); n != 0 { t.Fatalf("SES calls before the refusals: %d %v", n, f.prov.hits) } if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimSoloDomain); rec == nil { t.Fatal("RemoveDomain detached the domain before refusing") } } // A malformed domain record anywhere in the account makes the closure // incomplete, so the call refuses before any effect (as AppDomains does). func TestClaimMailClosureRefusesIncompleteDomainInventory(t *testing.T) { f := newClosureFixture(t, true) if _, err := f.base.Put(context.Background(), DomainKey(claimTestAccount, "broken.com"), []byte("{not json"), ""); err != nil { t.Fatal(err) } f.reset() if err := f.ensureSolo(); !errors.Is(err, ErrDomainInventory) { t.Fatalf("EnsureDomain over a malformed record: %v", err) } if n := f.prov.total(); n != 0 { t.Fatalf("SES calls: %d", n) } } // Rev 5 P2 (C6): the enabled teardown refuses an incomplete claim or record // inventory before its first effect. On the original, ListDomainClaims logged // and skipped the unreadable claim and Teardown returned nil. func TestClaimMailTeardownRefusesIncompleteInventory(t *testing.T) { for name, key := range map[string]string{ "claim": "email/domain-claims/orphan.example.com.json", "record": DomainKey(claimTestAccount, "broken.example.com"), } { t.Run(name, func(t *testing.T) { f := newClaimDomainFixture(t, "alpha") if _, err := f.base.Put(f.ctx, key, []byte("{not json"), ""); err != nil { t.Fatal(err) } f.reset() err := Teardown(f.ctx, f.marked, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha") if !errors.Is(err, ErrDomainInventory) { t.Fatalf("Teardown: %v", err) } if n := f.prov.total(); n != 0 { t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits) } if rec, _, _ := GetDomain(f.ctx, f.base, claimTestAccount, claimTestDomain); rec == nil { t.Fatal("teardown detached a domain before refusing") } }) } } // Flag off: an unmarked store keeps the lenient listings and the original // Teardown behaviour. func TestClaimMailTeardownUnmarkedStillSkipsBadClaims(t *testing.T) { f := newClaimDomainFixture(t, "alpha") if _, err := f.base.Put(f.ctx, "email/domain-claims/orphan.example.com.json", []byte("{not json"), ""); err != nil { t.Fatal(err) } if err := Teardown(f.ctx, f.log, f.prov, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "alpha"); err != nil { t.Fatalf("unmarked Teardown: %v", err) } } // seedDomain writes domain for apps through the UNMARKED store (no gate, no // admission), points SES at a healthy verdict, and verifies it when asked. func (f *claimDomainFixture) seedDomain(domain string, verify bool, apps ...string) { f.t.Helper() for _, app := range apps { if _, _, err := EnsureDomain(f.ctx, f.base, f.mem, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, app, domain, f.now); err != nil { f.t.Fatalf("seed %s/%s: %v", domain, app, err) } } f.mem.SetIdentityStatus("eu-west-1", domain, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) if verify { if _, err := VerifyDomain(f.ctx, f.base, f.mem, testDeployment(), claimTestAccount, domain, "eu-west-1", f.now); err != nil { f.t.Fatalf("verify %s: %v", domain, err) } } } // seedChain is the transitive shape: D1/A (pending), D2/A+B (verified) and // D3/B+C (verified when asked). A=alpha, B=beta, C=gamma. A reaches B through // D2, and B reaches C through D3, so the closure of D1 is {A, B, C}. func (f *claimDomainFixture) seedChain(verifyD3 bool) { f.t.Helper() f.seedDomain("d1.com", false, "alpha") f.seedDomain("d2.com", true, "alpha", "beta") f.seedDomain("d3.com", verifyD3, "beta", "gamma") f.reset() } func (f *claimDomainFixture) verifyD1() (*DomainRecord, error) { return VerifyDomain(f.ctx, f.marked, f.prov, testDeployment(), claimTestAccount, "d1.com", "eu-west-1", f.now) } func (f *claimDomainFixture) requireD1Pending() { f.t.Helper() rec, _, err := GetDomain(f.ctx, f.base, claimTestAccount, "d1.com") if err != nil || rec == nil { f.t.Fatalf("d1 record: %v %v", rec, err) } if st := rec.Regions["eu-west-1"].Status; st == StatusVerified { f.t.Fatal("d1 verified despite the refusal") } if n := f.log.putsTo(DomainKey(claimTestAccount, "d1.com")); n != 0 { f.t.Fatalf("d1 puts=%d", n) } } // The real VerifyDomain, not EnsureDomain: D1/A is verified while D2/A+B and // D3/B+C are verified shared domains. A's policy rewrite authorizes D2 against // B, and B's D3 against C, so a closed B OR C must refuse before the first SES // call, the domain write or the config-set rebind, and release the shared // gate. The open case verifies and settles every admission. func TestClaimMailVerifyDomainRefusesClosedTransitiveMember(t *testing.T) { for _, closed := range []string{"alpha", "beta", "gamma"} { t.Run("closed "+closed, func(t *testing.T) { f := newClaimDomainFixture(t) f.seedChain(true) f.setAdmission(closed, "purging") if _, err := f.verifyD1(); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("VerifyDomain with %s closed: %v", closed, err) } if n := f.prov.total(); n != 0 { t.Fatalf("SES calls before the refusal: %d %v", n, f.prov.hits) } f.requireD1Pending() f.requireGateFree() if f.adm.released != 0 { t.Fatalf("releases after a refused admission: %d", f.adm.released) } }) } t.Run("open", func(t *testing.T) { f := newClaimDomainFixture(t) f.seedChain(true) // Drift the binding so the verify rebinds and its nested // RefreshPolicies runs under the preflighted permits. if err := f.mem.SetIdentityConfigSet(f.ctx, "eu-west-1", "d1.com", ""); err != nil { t.Fatal(err) } rec, err := f.verifyD1() if err != nil { t.Fatalf("VerifyDomain with every member open: %v", err) } if rec == nil || rec.Regions["eu-west-1"].Status != StatusVerified { t.Fatalf("d1 not verified: %+v", rec) } if f.prov.total() == 0 { t.Fatal("open run never reached SES, so the zero counts above prove nothing") } f.requireGateFree() if f.adm.released != 1 { t.Fatalf("releases = %d, want 1", f.adm.released) } }) } // Scope: an UNVERIFIED shared domain grants nothing, so its members are not // needed. With D3 unverified, C is irrelevant but B (via verified D2) still is. func TestClaimMailVerifyDomainDoesNotAdmitUnverifiedDomainMembers(t *testing.T) { f := newClaimDomainFixture(t) f.seedChain(false) f.setAdmission("gamma", "purging") if _, err := f.verifyD1(); err != nil { t.Fatalf("VerifyDomain with gamma closed on an unverified domain: %v", err) } g := newClaimDomainFixture(t) g.seedChain(false) g.setAdmission("beta", "purging") if _, err := g.verifyD1(); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("VerifyDomain with beta closed on a verified shared domain: %v", err) } if n := g.prov.total(); n != 0 { t.Fatalf("SES calls before the refusal: %d", n) } } // Enable and CreateStandalone write the app's policy with EnsureUser over its // granted domains, and the boundary authorizes each against all its members, // so they carry the same closure: alpha -> D2 -> beta -> D3 -> gamma. func TestClaimMailEnableAndStandalonePreflightTransitiveMembers(t *testing.T) { _, pubB64, err := appconfig.GenerateKey() if err != nil { t.Fatal(err) } pub, err := appconfig.ParsePublicKey(pubB64, "test") if err != nil { t.Fatal(err) } run := map[string]func(f *claimDomainFixture) error{ "enable": func(f *claimDomainFixture) error { _, err := Enable(f.ctx, f.marked, f.prov, pub, testDeployment(), claimTestAccount, "alpha", f.now) return err }, "standalone": func(f *claimDomainFixture) error { _, err := CreateStandalone(f.ctx, f.marked, f.prov, testDeployment(), claimTestAccount, "alpha", "smoke", "owner@example.test", f.now) return err }, } for name, call := range run { t.Run(name, func(t *testing.T) { f := newClaimDomainFixture(t) f.seedChain(true) if _, err := Declare(f.ctx, f.base, claimTestAccount, "alpha", "ALPHA_SMTP", "noreply@d2.com", "owner@example.test", f.now); err != nil { t.Fatal(err) } f.reset() f.setAdmission("gamma", "purging") if err := call(f); !errors.Is(err, ErrClaimMailPending) { t.Fatalf("%s with a purging second-hop member: %v", name, err) } if n := f.prov.total(); n != 0 { t.Fatalf("backend calls before the refusal: %d %v", n, f.prov.hits) } f.requireGateFree() f.setAdmission("gamma", "open") f.reset() reopened := call(f) if errors.Is(reopened, ErrClaimMailPending) { t.Fatalf("%s after reopen still refused: %v", name, reopened) } f.prov.cmu.Lock() ensured := f.prov.hits["EnsureUser"] f.prov.cmu.Unlock() if ensured == 0 { t.Fatalf("%s never reached EnsureUser (err %v), so the zero count above proves nothing", name, reopened) } }) } } // dupListStore repeats every listed key, as a paged or eventually consistent // listing may. type dupListStore struct{ release.Store } func (d dupListStore) List(ctx context.Context, prefix string) ([]string, error) { keys, err := d.Store.List(ctx, prefix) return append(keys, keys...), err } func TestStrictInventoriesAppSchemaAndDuplicates(t *testing.T) { f := newClaimDomainFixture(t, "alpha") dup := dupListStore{f.base} claims, err := StrictDomainClaims(f.ctx, dup) if err != nil || len(claims) != 1 { t.Fatalf("duplicate claim listing: %v %v", claims, err) } recs, err := StrictListDomains(f.ctx, dup, claimTestAccount) if err != nil || len(recs) != 1 { t.Fatalf("duplicate record listing: %v %v", recs, err) } const key = "email/domain-claims/leg.example.com.json" cases := []struct { body string wantErr bool }{ // Legacy: no schema field, no app attribution. {`{"domain":"leg.example.com","account":"bab"}`, false}, {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"alpha"}`, false}, {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"Bad App!"}`, true}, {`{"schema":1,"domain":"leg.example.com","account":"bab","app":"../escape"}`, true}, {`{"schema":-1,"domain":"leg.example.com","account":"bab"}`, true}, {`{"schema":1,"domain":"leg.example.com","account":"not an account"}`, true}, } for _, tc := range cases { if _, err := f.base.Put(f.ctx, key, []byte(tc.body), ""); err != nil { t.Fatal(err) } _, err := StrictDomainClaims(f.ctx, f.base) if tc.wantErr && !errors.Is(err, ErrDomainInventory) { t.Fatalf("claim %s: err=%v, want ErrDomainInventory", tc.body, err) } if !tc.wantErr && err != nil { t.Fatalf("claim %s: unexpected err %v", tc.body, err) } if err := f.base.Delete(f.ctx, key); err != nil { t.Fatal(err) } } } func TestStrictDomainClaimsAndRecords(t *testing.T) { f := newClaimDomainFixture(t, "alpha") claims, err := StrictDomainClaims(f.ctx, f.base) if err != nil || len(claims) != 1 || claims[0].Domain != claimTestDomain { t.Fatalf("clean inventory: %v %v", claims, err) } recs, err := StrictListDomains(f.ctx, f.base, claimTestAccount) if err != nil || len(recs) != 1 { t.Fatalf("clean records: %v %v", recs, err) } for name, body := range map[string]string{ "malformed": "{not json", "schema": `{"schema":2,"domain":"bad.example.com","account":"bab"}`, "mismatch": `{"schema":1,"domain":"other.example.com","account":"bab"}`, } { if _, err := f.base.Put(f.ctx, "email/domain-claims/bad.example.com.json", []byte(body), ""); err != nil { t.Fatal(err) } if _, err := StrictDomainClaims(f.ctx, f.base); !errors.Is(err, ErrDomainInventory) { t.Fatalf("%s claim: %v", name, err) } if err := f.base.Delete(f.ctx, "email/domain-claims/bad.example.com.json"); err != nil { t.Fatal(err) } } // The lenient listing is unchanged. if _, err := f.base.Put(f.ctx, "email/domain-claims/bad.example.com.json", []byte("{not json"), ""); err != nil { t.Fatal(err) } if got, err := ListDomainClaims(f.ctx, f.base); err != nil || len(got) != 1 { t.Fatalf("lenient listing: %v %v", got, err) } } type strictInventoryFault struct { release.Store prefix, key string getErr, listErr error } func (s strictInventoryFault) List(ctx context.Context, prefix string) ([]string, error) { if prefix == s.prefix && s.listErr != nil { return []string{s.key}, s.listErr } return s.Store.List(ctx, prefix) } func (s strictInventoryFault) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.key && s.getErr != nil { return nil, "", s.getErr } return s.Store.Get(ctx, key) } func TestStrictMailInventoriesRejectPartialListsAndFailedReads(t *testing.T) { for _, kind := range []string{"claim", "record"} { for _, fault := range []string{"partial-list", "failed-read", "disappeared-read"} { t.Run(kind+"/"+fault, func(t *testing.T) { f := newClaimDomainFixture(t, "alpha") s := strictInventoryFault{Store: f.base, prefix: domainClaimsPrefix, key: domainClaimKey(claimTestDomain)} if kind == "record" { s.prefix = DomainsPrefix(claimTestAccount) s.key = DomainKey(claimTestAccount, claimTestDomain) } switch fault { case "partial-list": s.listErr = io.ErrUnexpectedEOF case "failed-read": s.getErr = io.ErrUnexpectedEOF case "disappeared-read": s.getErr = os.ErrNotExist } if kind == "claim" { got, err := StrictDomainClaims(f.ctx, s) if err == nil || got != nil { t.Fatalf("partial claim inventory used: %+v %v", got, err) } } else { got, err := StrictListDomains(f.ctx, s, claimTestAccount) if err == nil || got != nil { t.Fatalf("partial record inventory used: %+v %v", got, err) } } }) } } } func TestStrictMailInventoryRefusesMalformedMembersAndRegions(t *testing.T) { for name, body := range map[string]string{ "negative-schema": `{"schema":-1,"domain":"bad.example.com"}`, "invalid-member": `{"schema":1,"domain":"bad.example.com","apps":["../other"]}`, "null-region": `{"schema":1,"domain":"bad.example.com","regions":{"eu-west-1":null}}`, } { t.Run(name, func(t *testing.T) { f := newClaimDomainFixture(t, "alpha") if _, err := f.base.Put(f.ctx, DomainKey(claimTestAccount, "bad.example.com"), []byte(body), ""); err != nil { t.Fatal(err) } if got, err := StrictListDomains(f.ctx, f.base, claimTestAccount); !errors.Is(err, ErrDomainInventory) || got != nil { t.Fatalf("malformed inventory: %+v %v", got, err) } }) } }