carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/url"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/aws/aws-sdk-go-v2/aws"
13 "github.com/aws/aws-sdk-go-v2/service/sesv2"
14
15 "github.com/carlosframework/platform/internal/dnsrec"
16 "github.com/carlosframework/platform/internal/mail"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20type fakeSES struct {
21 sent []*sesv2.SendEmailInput
22 err error
23}
24
25func (f *fakeSES) SendEmail(_ context.Context, in *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
26 if f.err != nil {
27 return nil, f.err
28 }
29 f.sent = append(f.sent, in)
30 return &sesv2.SendEmailOutput{MessageId: aws.String("m-1")}, nil
31}
32
33var verifiedStatus = mail.Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}
34
35func testMailDeployment() mail.Deployment {
36 return mail.Deployment{Region: "eu-west-1", PlatformSuffix: "oncarlos.test", SNSTopicARN: "arn:aws:sns:eu-west-1:1:t"}
37}
38
39func TestConsoleMailFrom(t *testing.T) {
40 cases := []struct{ explicit, suffix, want string }{
41 {"invites@shop.example", "oncarlos.test", "invites@shop.example"},
42 {"", "oncarlos.test", "noreply@console-mail.bab.oncarlos.test"},
43 {"", "", ""},
44 {" Invites@Shop.example ", "", "invites@shop.example"},
45 }
46 for _, c := range cases {
47 if got := ConsoleMailFrom(c.explicit, c.suffix); got != c.want {
48 t.Errorf("ConsoleMailFrom(%q, %q) = %q, want %q", c.explicit, c.suffix, got, c.want)
49 }
50 }
51}
52
53func TestConsoleMailerSendsOnceVerified(t *testing.T) {
54 ctx := context.Background()
55 s := release.NewDirStore(t.TempDir())
56 p := mail.NewMemProvisioner("1")
57 p.StatusOnEnsure = verifiedStatus
58 ses := &fakeSES{}
59 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
60 if err := m.Ensure(ctx); err != nil {
61 t.Fatal(err)
62 }
63 if err := m.Send(ctx, "bill@gmail.com", "subj", "body"); err != nil {
64 t.Fatal(err)
65 }
66 if len(ses.sent) != 1 {
67 t.Fatalf("sent %d, want 1", len(ses.sent))
68 }
69 in := ses.sent[0]
70 if aws.ToString(in.FromEmailAddress) != "noreply@console-mail.bab.oncarlos.test" ||
71 in.Destination.ToAddresses[0] != "bill@gmail.com" ||
72 aws.ToString(in.TenantName) != mail.TenantName(release.OpsAccount) ||
73 aws.ToString(in.ConfigurationSetName) != mail.ConfigSetName(release.OpsAccount) {
74 t.Fatalf("SendEmailInput = %+v", in)
75 }
76}
77
78// Review Focus 4.
79func TestConsoleMailerRefusesUntilVerified(t *testing.T) {
80 ctx := context.Background()
81 s := release.NewDirStore(t.TempDir())
82 p := mail.NewMemProvisioner("1") // identities stay PENDING
83 ses := &fakeSES{}
84 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
85 if err := m.Send(ctx, "bill@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
86 t.Fatalf("Send before verification = %v, want ErrConsoleMailNotReady", err)
87 }
88 if len(ses.sent) != 0 {
89 t.Fatal("sent through an unverified identity")
90 }
91}
92
93func TestConsoleMailerReady(t *testing.T) {
94 ctx := context.Background()
95 deployment := testMailDeployment()
96 from := "noreply@console-mail.bab.oncarlos.test"
97
98 // Unverified: false, and nothing goes out through SES.
99 p := mail.NewMemProvisioner("1") // identities stay PENDING
100 ses := &fakeSES{}
101 m := newSESConsoleMailer(from, release.NewDirStore(t.TempDir()), p, dnsrec.NewMemWriter(), deployment, ses)
102 if m.Ready(ctx) {
103 t.Fatal("Ready = true for an unverified identity")
104 }
105 if len(ses.sent) != 0 {
106 t.Fatal("Ready sent mail")
107 }
108
109 // Verified: true, and still nothing sent.
110 p = mail.NewMemProvisioner("1")
111 p.StatusOnEnsure = verifiedStatus
112 ses = &fakeSES{}
113 m = newSESConsoleMailer(from, release.NewDirStore(t.TempDir()), p, dnsrec.NewMemWriter(), deployment, ses)
114 if !m.Ready(ctx) {
115 t.Fatal("Ready = false for a verified identity")
116 }
117 if len(ses.sent) != 0 {
118 t.Fatal("Ready sent mail")
119 }
120
121 // A context that has already ended: false, even though the identity is
122 // verified and cached.
123 ended, cancel := context.WithCancel(ctx)
124 cancel()
125 for i := 0; i < 20; i++ {
126 if m.Ready(ended) {
127 t.Fatal("Ready = true on an ended context")
128 }
129 }
130}
131
132func TestServerConsoleName(t *testing.T) {
133 for _, c := range []struct{ set, want string }{
134 {"", "the CARLOS platform"},
135 {" ", "the CARLOS platform"},
136 {" Acme Console ", "Acme Console"},
137 } {
138 s := &Server{consoleNameCfg: c.set}
139 if got := s.consoleName(); got != c.want {
140 t.Errorf("consoleName() with %q = %q, want %q", c.set, got, c.want)
141 }
142 }
143}
144
145func TestConsoleMailerCachesAVerifiedIdentity(t *testing.T) {
146 ctx := context.Background()
147 s := release.NewDirStore(t.TempDir())
148 p := mail.NewMemProvisioner("1")
149 p.StatusOnEnsure = verifiedStatus
150 ses := &fakeSES{}
151 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
152 if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
153 t.Fatal(err)
154 }
155 p.FailNext["IdentityStatus"] = errors.New("SES is down")
156 if err := m.Send(ctx, "b@gmail.com", "s", "b"); err != nil {
157 t.Fatalf("second send re-checked SES: %v", err)
158 }
159}
160
161func TestConsoleMailerRechecksPendingAfterFiveMinutes(t *testing.T) {
162 ctx := context.Background()
163 s := release.NewDirStore(t.TempDir())
164 p := mail.NewMemProvisioner("1")
165 ses := &fakeSES{}
166 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), testMailDeployment(), ses)
167 now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
168 m.now = func() time.Time { return now }
169 if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
170 t.Fatalf("first = %v", err)
171 }
172 for k := range p.Identities {
173 p.Identities[k].Status = verifiedStatus
174 }
175 if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
176 t.Fatalf("within 5 minutes = %v, want the cached not-ready", err)
177 }
178 now = now.Add(5*time.Minute + time.Second)
179 if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
180 t.Fatalf("after 5 minutes = %v, want sent", err)
181 }
182}
183
184// TestConsoleMailerRecoversFromALapsedDomainRecord covers the ruling added
185// alongside mail #216: ready() must clear m.ensured when VerifyDomain errors
186// (the domain record, or its claim, is gone — e.g. an unverified claim
187// lapsed, mail.ClaimLapse), so the NEXT recheck re-runs EnsureDomain instead
188// of failing forever on a stale "ensured" latch.
189func TestConsoleMailerRecoversFromALapsedDomainRecord(t *testing.T) {
190 ctx := context.Background()
191 s := release.NewDirStore(t.TempDir())
192 p := mail.NewMemProvisioner("1") // identities stay PENDING until driven verified below
193 ses := &fakeSES{}
194 dep := testMailDeployment()
195 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", s, p, dnsrec.NewMemWriter(), dep, ses)
196 now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
197 m.now = func() time.Time { return now }
198
199 // A successful Ensure writes the domain record and latches m.ensured,
200 // same as the console's startup call would.
201 if err := m.Ensure(ctx); err != nil {
202 t.Fatalf("ensure: %v", err)
203 }
204
205 key := mail.DomainKey(release.OpsAccount, m.domain)
206 if _, _, err := s.Get(ctx, key); err != nil {
207 t.Fatalf("domain record missing before deletion: %v", err)
208 }
209 // The record (or its claim) is gone — the shape an unverified claim's
210 // lapse (mail.ClaimLapse) leaves behind. m.ensured is still true, so
211 // without the ruling's fix ready() would call ensureLocked, find
212 // m.ensured latched, skip EnsureDomain entirely, and fail this way
213 // FOREVER — no recheck would ever repair it.
214 if err := s.Delete(ctx, key); err != nil {
215 t.Fatalf("delete domain record: %v", err)
216 }
217
218 now = now.Add(consoleMailPendingRecheck + time.Second)
219 if err := m.Send(ctx, "a@gmail.com", "s", "b"); !errors.Is(err, ErrConsoleMailNotReady) {
220 t.Fatalf("send after lapse = %v, want ErrConsoleMailNotReady", err)
221 }
222
223 // Drive the (still-extant, provisioner-side) identity to verified, the
224 // same seam TestConsoleMailerRechecksPendingAfterFiveMinutes uses.
225 for k := range p.Identities {
226 p.Identities[k].Status = verifiedStatus
227 }
228
229 now = now.Add(consoleMailPendingRecheck + time.Second)
230 if err := m.Send(ctx, "a@gmail.com", "s", "b"); err != nil {
231 t.Fatalf("send after recheck re-ran EnsureDomain: %v", err)
232 }
233 if _, _, err := s.Get(ctx, key); err != nil {
234 t.Fatalf("domain record should exist again after re-ensure: %v", err)
235 }
236}
237
238// Review Focus 5.
239func TestConsoleMailAppIsReservedInOps(t *testing.T) {
240 fx := newConsoleFixture(t)
241 ops, err := fx.st.AccountByID(OpsAccountID)
242 if err != nil {
243 t.Fatal(err)
244 }
245 if got, _, _ := fx.srv.claimApp(context.Background(), ops, consoleMailApp, "paul@keymail.dev", "", true, regionReq{}); got != claimReserved {
246 t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got)
247 }
248}
249
250// Final review Important #1: rename was the other door onto the reserved
251// name. Refused in the ops account, the same words as "new".
252func TestRenameOntoConsoleMailIsRefusedInOps(t *testing.T) {
253 fx := newConsoleFixture(t)
254 freshApp(t, fx, "sprout")
255 c := signInPaul(t, fx)
256 csrf := sessionToken(t, fx, c)
257
258 status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
259 url.Values{"csrf": {csrf}, "to": {consoleMailApp}, "confirmed": {"1"}})
260 if status != http.StatusUnprocessableEntity || !strings.Contains(body, "That name is reserved.") {
261 t.Fatalf("rename onto %q = %d, want 422 with the reserved refusal:\n%s", consoleMailApp, status, body)
262 }
263 if has, err := fx.st.HasAppIn(OpsAccountID, consoleMailApp); err != nil || has {
264 t.Fatalf("HasAppIn(%s) = %v %v, want no rename", consoleMailApp, has, err)
265 }
266 if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
267 t.Fatalf("HasAppIn(sprout) = %v %v, want the app untouched", has, err)
268 }
269}
270
271func TestReservedAppName(t *testing.T) {
272 ops := &Account{Sqid: release.OpsAccount}
273 other := &Account{Sqid: "xyz"}
274 for _, tc := range []struct {
275 acct *Account
276 name string
277 want bool
278 }{
279 {ops, "new", true},
280 {other, "new", true},
281 {ops, consoleMailApp, true},
282 {other, consoleMailApp, false},
283 {ops, "blog", false},
284 } {
285 if got := reservedAppName(tc.acct, tc.name); got != tc.want {
286 t.Errorf("reservedAppName(%s, %q) = %v, want %v", tc.acct.Sqid, tc.name, got, tc.want)
287 }
288 }
289}
290
291// stallingProvisioner never answers EnsureTenant until released, whatever
292// the context says: the AWS SDK call that hangs.
293type stallingProvisioner struct {
294 *mail.MemProvisioner
295 entered chan struct{}
296 release chan struct{}
297}
298
299func (p *stallingProvisioner) EnsureTenant(context.Context, string) error {
300 close(p.entered)
301 <-p.release
302 return nil
303}
304
305// Final review Important #3: an ensure stalled inside the provider holds the
306// mailer; a Send must still give up at its own deadline, not the ensure's.
307func TestConsoleMailerSendHonoursItsDeadlineWhileEnsureHolds(t *testing.T) {
308 p := &stallingProvisioner{MemProvisioner: mail.NewMemProvisioner("1"),
309 entered: make(chan struct{}), release: make(chan struct{})}
310 m := newSESConsoleMailer("noreply@console-mail.bab.oncarlos.test", release.NewDirStore(t.TempDir()), p,
311 dnsrec.NewMemWriter(), testMailDeployment(), &fakeSES{})
312 ensured := make(chan struct{})
313 go func() { defer close(ensured); m.Ensure(context.Background()) }()
314 // Let the ensure finish before TempDir's cleanup removes its store.
315 defer func() { close(p.release); <-ensured }()
316 <-p.entered
317
318 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
319 defer cancel()
320 done := make(chan error, 1)
321 go func() { done <- m.Send(ctx, "bill@gmail.com", "s", "b") }()
322 select {
323 case err := <-done:
324 if !errors.Is(err, ErrConsoleMailNotReady) || !errors.Is(err, context.DeadlineExceeded) {
325 t.Fatalf("Send while the ensure holds = %v, want ErrConsoleMailNotReady wrapping DeadlineExceeded", err)
326 }
327 case <-time.After(time.Second):
328 t.Fatal("Send blocked behind a stalled ensure past its own deadline")
329 }
330}
331