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 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/dnsrec"
14 "github.com/carlosframework/platform/internal/hostops"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18const claimTestAccount = "bag"
19const claimTestDomain = "shared.com"
20
21// claimLogStore records every Get and Put. It embeds only release.Store, so it is
22// not a ClaimMailStore and behaves as an unmarked store.
23type claimLogStore struct {
24 release.Store
25 mu sync.Mutex
26 gets, puts []string
27}
28
29func (l *claimLogStore) Get(ctx context.Context, key string) ([]byte, string, error) {
30 l.mu.Lock()
31 l.gets = append(l.gets, key)
32 l.mu.Unlock()
33 return l.Store.Get(ctx, key)
34}
35func (l *claimLogStore) Put(ctx context.Context, key string, body []byte, tag string) (string, error) {
36 l.mu.Lock()
37 l.puts = append(l.puts, key)
38 l.mu.Unlock()
39 return l.Store.Put(ctx, key, body, tag)
40}
41func (l *claimLogStore) reset() { l.mu.Lock(); l.gets, l.puts = nil, nil; l.mu.Unlock() }
42func (l *claimLogStore) admissionGets() int {
43 l.mu.Lock()
44 defer l.mu.Unlock()
45 n := 0
46 for _, k := range l.gets {
47 if strings.HasSuffix(k, "/admission.json") {
48 n++
49 }
50 }
51 return n
52}
53func (l *claimLogStore) putsTo(key string) int {
54 l.mu.Lock()
55 defer l.mu.Unlock()
56 n := 0
57 for _, k := range l.puts {
58 if k == key {
59 n++
60 }
61 }
62 return n
63}
64
65// countingProv counts every Provisioner call, reads included.
66type countingProv struct {
67 *MemProvisioner
68 cmu sync.Mutex
69 hits map[string]int
70}
71
72func (c *countingProv) hit(name string) { c.cmu.Lock(); c.hits[name]++; c.cmu.Unlock() }
73func (c *countingProv) total() int {
74 c.cmu.Lock()
75 defer c.cmu.Unlock()
76 n := 0
77 for _, v := range c.hits {
78 n += v
79 }
80 return n
81}
82func (c *countingProv) reset() { c.cmu.Lock(); c.hits = map[string]int{}; c.cmu.Unlock() }
83func (c *countingProv) EnsureTenant(ctx context.Context, name string) error {
84 c.hit("EnsureTenant")
85 return c.MemProvisioner.EnsureTenant(ctx, name)
86}
87func (c *countingProv) EnsureConfigSet(ctx context.Context, region, name, topic string) error {
88 c.hit("EnsureConfigSet")
89 return c.MemProvisioner.EnsureConfigSet(ctx, region, name, topic)
90}
91func (c *countingProv) SetIdentityConfigSet(ctx context.Context, region, domain, set string) error {
92 c.hit("SetIdentityConfigSet")
93 return c.MemProvisioner.SetIdentityConfigSet(ctx, region, domain, set)
94}
95func (c *countingProv) AssociateTenantResource(ctx context.Context, tenant, resource string) error {
96 c.hit("AssociateTenantResource")
97 return c.MemProvisioner.AssociateTenantResource(ctx, tenant, resource)
98}
99func (c *countingProv) DisassociateTenantResource(ctx context.Context, tenant, resource string) error {
100 c.hit("DisassociateTenantResource")
101 return c.MemProvisioner.DisassociateTenantResource(ctx, tenant, resource)
102}
103func (c *countingProv) EnsureIdentity(ctx context.Context, region, domain, from string, adopt bool) (Identity, error) {
104 c.hit("EnsureIdentity")
105 return c.MemProvisioner.EnsureIdentity(ctx, region, domain, from, adopt)
106}
107func (c *countingProv) IdentityStatus(ctx context.Context, region, domain string) (Status, error) {
108 c.hit("IdentityStatus")
109 return c.MemProvisioner.IdentityStatus(ctx, region, domain)
110}
111func (c *countingProv) DeleteIdentity(ctx context.Context, region, domain string) error {
112 c.hit("DeleteIdentity")
113 return c.MemProvisioner.DeleteIdentity(ctx, region, domain)
114}
115func (c *countingProv) EnsureUser(ctx context.Context, name, path string, identities, sets, domains []string) (string, error) {
116 c.hit("EnsureUser")
117 return c.MemProvisioner.EnsureUser(ctx, name, path, identities, sets, domains)
118}
119func (c *countingProv) MintKey(ctx context.Context, user string) (string, string, error) {
120 c.hit("MintKey")
121 return c.MemProvisioner.MintKey(ctx, user)
122}
123func (c *countingProv) SetKeyActive(ctx context.Context, user, key string, active bool) error {
124 c.hit("SetKeyActive")
125 return c.MemProvisioner.SetKeyActive(ctx, user, key, active)
126}
127func (c *countingProv) DeleteKey(ctx context.Context, user, key string) error {
128 c.hit("DeleteKey")
129 return c.MemProvisioner.DeleteKey(ctx, user, key)
130}
131func (c *countingProv) ReapKeys(ctx context.Context, user string, keep []string) error {
132 c.hit("ReapKeys")
133 return c.MemProvisioner.ReapKeys(ctx, user, keep)
134}
135func (c *countingProv) DeleteUser(ctx context.Context, user string) error {
136 c.hit("DeleteUser")
137 return c.MemProvisioner.DeleteUser(ctx, user)
138}
139
140type claimDomainFixture struct {
141 t *testing.T
142 ctx context.Context
143 base release.Store
144 log *claimLogStore
145 marked ClaimMailStore
146 mem *MemProvisioner
147 prov *countingProv
148 now time.Time
149 mg *memGate
150 adm *claimFixtureAdmitter
151 states map[string]string
152}
153
154var claimTestOwner = ClaimMailOwner{Console: "c", Enrollment: "e", Controller: "one"}
155
156// memGate is a test-only ClaimMailGate holding {state,id,revision} in memory.
157type memGate struct {
158 mu sync.Mutex
159 State string
160 id string
161 Revision int
162}
163type memGateKey struct{}
164
165func (g *memGate) AcquireClaimMailGate(ctx context.Context, _ ClaimMailOwner, _ ClaimMailScope) (context.Context, string, error) {
166 g.mu.Lock()
167 defer g.mu.Unlock()
168 if g.State == "held" {
169 return nil, "", ErrClaimMailPending
170 }
171 g.Revision++
172 g.State = "held"
173 g.id = fmt.Sprintf("gate-%d", g.Revision)
174 return context.WithValue(ctx, memGateKey{}, g.id), g.id, nil
175}
176func (g *memGate) owns(ctx context.Context) bool {
177 id, _ := ctx.Value(memGateKey{}).(string)
178 return g.State == "held" && id == g.id
179}
180func (g *memGate) CheckClaimMailGate(ctx context.Context) error {
181 g.mu.Lock()
182 defer g.mu.Unlock()
183 if !g.owns(ctx) {
184 return ErrClaimMailPending
185 }
186 return nil
187}
188func (g *memGate) ReleaseClaimMailGate(ctx context.Context) error {
189 g.mu.Lock()
190 defer g.mu.Unlock()
191 if !g.owns(ctx) {
192 return ErrClaimMailPending
193 }
194 g.Revision++
195 g.State = "free"
196 return nil
197}
198func (g *memGate) snapshot() memGate {
199 g.mu.Lock()
200 defer g.mu.Unlock()
201 return memGate{State: g.State, id: g.id, Revision: g.Revision}
202}
203
204// fixtureAdmission is the admitter's result: it adds no authority of its own.
205type fixtureAdmission struct {
206 ctx context.Context
207 released *int
208 mu *sync.Mutex
209}
210
211func (a fixtureAdmission) Context() context.Context { return a.ctx }
212func (a fixtureAdmission) Settle(context.Context, error, []hostops.MailAttempt) error { return nil }
213func (a fixtureAdmission) Release(context.Context) error {
214 a.mu.Lock()
215 defer a.mu.Unlock()
216 *a.released++
217 return nil
218}
219
220// claimFixtureAdmitter refuses an app whose fixture state is "purging". Each
221// app it checks is logged as an admission read so the call-count assertions
222// keep their meaning.
223type claimFixtureAdmitter struct {
224 f *claimDomainFixture
225 mu sync.Mutex
226 released int
227}
228
229func (a *claimFixtureAdmitter) AdmitClaimMailApps(ctx context.Context, _ ClaimMailHold, apps []ClaimMailApp) (ClaimMailAdmission, error) {
230 for _, app := range apps {
231 a.f.log.mu.Lock()
232 a.f.log.gets = append(a.f.log.gets, "apps/"+app.Account+"/"+app.App+"/admission.json")
233 a.f.log.mu.Unlock()
234 if a.f.states[app.App] == "purging" {
235 return nil, ErrClaimMailPending
236 }
237 }
238 return fixtureAdmission{ctx: ctx, released: &a.released, mu: &a.mu}, nil
239}
240
241func (f *claimDomainFixture) useAdmitter(a ClaimMailAdmitter) {
242 f.t.Helper()
243 m, err := NewClaimMailStore(f.log, f.mg, a, claimTestOwner)
244 if err != nil {
245 f.t.Fatal(err)
246 }
247 f.marked = m
248}
249
250// newClaimDomainFixture seeds one shared domain whose Apps are members, through the
251// unmarked store, then opens an admission for alpha, beta and gamma.
252func newClaimDomainFixture(t *testing.T, members ...string) *claimDomainFixture {
253 t.Helper()
254 ctx := context.Background()
255 base := release.NewDirStore(t.TempDir())
256 mem := NewMemProvisioner("1")
257 now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
258 for _, app := range members {
259 if _, _, err := EnsureDomain(ctx, base, mem, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, app, claimTestDomain, now); err != nil {
260 t.Fatalf("seed %s: %v", app, err)
261 }
262 }
263 mem.SetIdentityStatus("eu-west-1", claimTestDomain, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"})
264 log := &claimLogStore{Store: base}
265 f := &claimDomainFixture{t: t, ctx: ctx, base: base, log: log, mem: mem, now: now, mg: &memGate{State: "free"}, states: map[string]string{},
266 prov: &countingProv{MemProvisioner: mem, hits: map[string]int{}}}
267 f.adm = &claimFixtureAdmitter{f: f}
268 for _, app := range []string{"alpha", "beta", "gamma"} {
269 f.setAdmission(app, "open")
270 }
271 f.useAdmitter(f.adm)
272 f.reset()
273 return f
274}
275func (f *claimDomainFixture) reset() { f.log.reset(); f.prov.reset() }
276
277// setAdmission sets the state the fixture admitter reports: "open" or
278// "purging".
279func (f *claimDomainFixture) setAdmission(app, state string) { f.t.Helper(); f.states[app] = state }
280func (f *claimDomainFixture) domainRaw() ([]byte, string) {
281 f.t.Helper()
282 raw, etag, err := f.base.Get(f.ctx, DomainKey(claimTestAccount, claimTestDomain))
283 if err != nil {
284 f.t.Fatal(err)
285 }
286 return raw, etag
287}
288func (f *claimDomainFixture) requireUntouched(raw []byte, etag string) {
289 f.t.Helper()
290 gotRaw, gotEtag := f.domainRaw()
291 if !bytes.Equal(gotRaw, raw) || gotEtag != etag {
292 f.t.Fatalf("domain record changed: etag %s -> %s", etag, gotEtag)
293 }
294 if n := f.log.putsTo(DomainKey(claimTestAccount, claimTestDomain)); n != 0 {
295 f.t.Fatalf("domain puts=%d", n)
296 }
297 if n := f.prov.total(); n != 0 {
298 f.t.Fatalf("backend calls=%d %v", n, f.prov.hits)
299 }
300}
301func (f *claimDomainFixture) gate() memGate { f.t.Helper(); return f.mg.snapshot() }
302
303// requireGateFree: one acquire and one release since the gate was first used.
304func (f *claimDomainFixture) requireGateFree() {
305 f.t.Helper()
306 if g := f.gate(); g.State != "free" || g.Revision != 2 {
307 f.t.Fatalf("gate not released cleanly: %+v", &g)
308 }
309}
310
311func TestClaimMailPutDomainPreflightsEveryAffectedApp(t *testing.T) {
312 cases := []struct {
313 name string
314 members []string
315 purging string
316 proposed []string
317 }{
318 {"proposal drops the purging member", []string{"alpha", "beta"}, "beta", []string{"alpha"}},
319 {"proposal adds a purging app", []string{"alpha"}, "gamma", []string{"alpha", "gamma"}},
320 {"proposal is reordered", []string{"alpha", "beta", "gamma"}, "gamma", []string{"gamma", "beta", "alpha"}},
321 }
322 for _, tc := range cases {
323 t.Run(tc.name, func(t *testing.T) {
324 f := newClaimDomainFixture(t, tc.members...)
325 rec, etag, err := GetDomain(f.ctx, f.base, claimTestAccount, claimTestDomain)
326 if err != nil {
327 t.Fatal(err)
328 }
329 beforeRaw, beforeEtag := f.domainRaw()
330 f.setAdmission(tc.purging, "purging")
331 f.reset()
332 rec.Apps = tc.proposed
333 if _, err = PutDomain(f.ctx, f.marked, claimTestAccount, rec, etag); !errors.Is(err, ErrClaimMailPending) {
334 t.Fatalf("PutDomain with a purging affected app: %v", err)
335 }
336 f.requireUntouched(beforeRaw, beforeEtag)
337 f.requireGateFree()
338 if f.log.admissionGets() == 0 {
339 t.Fatal("preflight never read admissions")
340 }
341 // The gate is free and the refusal was about the purge only: reopening it lets the same write through.
342 f.setAdmission(tc.purging, "open")
343 if _, err = PutDomain(f.ctx, f.marked, claimTestAccount, rec, etag); err != nil {
344 t.Fatalf("retry after refusal: %v", err)
345 }
346 if _, after := f.domainRaw(); after == beforeEtag {
347 t.Fatal("control write did not change the domain record")
348 }
349 })
350 }
351}
352
353func TestClaimMailVerifyDomainPreflightsEveryMemberBeforeBackend(t *testing.T) {
354 verify := func(f *claimDomainFixture) (*DomainRecord, error) {
355 return VerifyDomain(f.ctx, f.marked, f.prov, testDeployment(), claimTestAccount, claimTestDomain, "eu-west-1", f.now)
356 }
357 t.Run("one member purging", func(t *testing.T) {
358 f := newClaimDomainFixture(t, "alpha", "beta", "gamma")
359 beforeRaw, beforeEtag := f.domainRaw()
360 f.setAdmission("beta", "purging")
361 f.reset()
362 rec, err := verify(f)
363 if !errors.Is(err, ErrClaimMailPending) || rec != nil {
364 t.Fatalf("VerifyDomain: rec=%v err=%v", rec, err)
365 }
366 f.requireUntouched(beforeRaw, beforeEtag)
367 f.requireGateFree()
368 if f.log.admissionGets() == 0 {
369 t.Fatal("preflight never read admissions")
370 }
371 f.setAdmission("beta", "open")
372 f.reset()
373 rec, err = verify(f)
374 if err != nil {
375 t.Fatalf("VerifyDomain after reopen: %v", err)
376 }
377 if rec.Regions["eu-west-1"].Status != StatusVerified {
378 t.Fatalf("status=%q", rec.Regions["eu-west-1"].Status)
379 }
380 if f.prov.total() == 0 {
381 t.Fatal("control run never reached the backend, so the zero count above proves nothing")
382 }
383 })
384 t.Run("membership is read fresh at call time", func(t *testing.T) {
385 f := newClaimDomainFixture(t, "alpha")
386 // beta joins after the fixture exists and is already purging when the call starts.
387 if _, _, err := EnsureDomain(f.ctx, f.base, f.mem, dnsrec.NewMemWriter(), testDeployment(), claimTestAccount, "beta", claimTestDomain, f.now); err != nil {
388 t.Fatal(err)
389 }
390 f.mem.SetIdentityStatus("eu-west-1", claimTestDomain, Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"})
391 beforeRaw, beforeEtag := f.domainRaw()
392 f.setAdmission("beta", "purging")
393 f.reset()
394 if _, err := verify(f); !errors.Is(err, ErrClaimMailPending) {
395 t.Fatalf("VerifyDomain: %v", err)
396 }
397 f.requireUntouched(beforeRaw, beforeEtag)
398 f.requireGateFree()
399 })
400}
401
402func TestClaimMailDomainWrappersUnmarkedDoNoNewReads(t *testing.T) {
403 f := newClaimDomainFixture(t, "alpha", "beta")
404 f.setAdmission("beta", "purging")
405 rec, etag, err := GetDomain(f.ctx, f.base, claimTestAccount, claimTestDomain)
406 if err != nil {
407 t.Fatal(err)
408 }
409 f.reset()
410 // f.log is not a ClaimMailStore: the purging member must not matter.
411 if _, err = PutDomain(f.ctx, f.log, claimTestAccount, rec, etag); err != nil {
412 t.Fatalf("unmarked PutDomain: %v", err)
413 }
414 if len(f.log.gets) != 0 {
415 t.Fatalf("unmarked PutDomain read %v", f.log.gets)
416 }
417 f.reset()
418 if _, err = VerifyDomain(f.ctx, f.log, f.prov, testDeployment(), claimTestAccount, claimTestDomain, "eu-west-1", f.now); err != nil {
419 t.Fatalf("unmarked VerifyDomain: %v", err)
420 }
421 for _, k := range f.log.gets {
422 if strings.HasSuffix(k, "/admission.json") {
423 t.Fatalf("unmarked VerifyDomain read %s", k)
424 }
425 }
426 if g := f.gate(); g.Revision != 0 {
427 t.Fatalf("unmarked call used the gate: %+v", &g)
428 }
429}
430