| 1 | package mail |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | const 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. |
| 21 | func 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 | |
| 33 | func (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). |
| 43 | func 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. |
| 76 | func 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. |
| 86 | func 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). |
| 109 | func 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. |
| 126 | func 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. |
| 153 | func 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. |
| 165 | func (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}. |
| 183 | func (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 | |
| 191 | func (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 | |
| 195 | func (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. |
| 214 | func 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. |
| 261 | func 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. |
| 283 | func 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. |
| 337 | type dupListStore struct{ release.Store } |
| 338 | |
| 339 | func (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 | |
| 344 | func 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 | |
| 386 | func 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 | |
| 420 | type strictInventoryFault struct { |
| 421 | release.Store |
| 422 | prefix, key string |
| 423 | getErr, listErr error |
| 424 | } |
| 425 | |
| 426 | func (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 | } |
| 432 | func (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 | |
| 439 | func 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 | |
| 473 | func 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 | |