carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for `carlos email` (tenant-email spec, task 18): the wire shape the
2// apiSession methods send (mail_cli.go, matching apimail_test.go's server
3// side), the honesty rules the print* functions must follow — a pasteable
4// record set, a password shown exactly once, a permanent refusal that never
5// suggests a retry, a timeout that names the pending axis and fails loud —
6// and the full `enable` flow driven against an in-process fake console. Same
7// two-layer shape domains_cli_test.go and fleets_cli_test.go established:
8// apiSession-level tests against a bare httptest server, run*-level tests
9// against a fixture console with a credential written straight to disk, and
10// usage-error/dispatch-refusal paths run as a real subprocess (buildCarlos +
11// carlosTestEnv + a scratch cmd.Dir — CLAUDE.md's rule for anything that
12// shells out to the binary).
13package main
14
15import (
16 "bufio"
17 "bytes"
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "log"
24 "mime"
25 "mime/multipart"
26 "net"
27 "net/http"
28 "net/http/httptest"
29 "net/mail"
30 "os/exec"
31 "strings"
32 "testing"
33 "time"
34)
35
36// ---- apiSession-level: path + body + wire-shape round trips ---------------
37
38func TestAPISessionEmailDeclareWireShape(t *testing.T) {
39 var gotPath, gotMethod string
40 var gotBody struct {
41 EnvPrefix string `json:"env_prefix"`
42 From string `json:"from"`
43 }
44 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45 gotPath, gotMethod = r.URL.Path, r.Method
46 json.NewDecoder(r.Body).Decode(&gotBody)
47 json.NewEncoder(w).Encode(map[string]any{
48 "app": "harbourmail", "declared": map[string]any{"env_prefix": "CARLOS_SMTP", "from": "noreply@x.com"},
49 "standalone": []any{}, "domains": []any{}, "env_names": []string{"CARLOS_SMTP_HOST"},
50 })
51 }))
52 defer ts.Close()
53
54 res, err := newTestSession(ts.URL).emailDeclare(context.Background(), apiAcct{Seg: "acct"}, "harbourmail", "", "noreply@x.com")
55 if err != nil {
56 t.Fatalf("emailDeclare: %v", err)
57 }
58 if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/acct/harbourmail/email" {
59 t.Fatalf("method/path = %s %s", gotMethod, gotPath)
60 }
61 if gotBody.From != "noreply@x.com" {
62 t.Fatalf("body From = %q", gotBody.From)
63 }
64 if res.Declared == nil || res.Declared.From != "noreply@x.com" {
65 t.Fatalf("result = %+v", res.Declared)
66 }
67}
68
69func TestAPISessionEmailStatus404IsNotDeclared(t *testing.T) {
70 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71 w.WriteHeader(http.StatusNotFound)
72 json.NewEncoder(w).Encode(map[string]any{"error": "no email is declared for this app — declare it first"})
73 }))
74 defer ts.Close()
75
76 _, err := newTestSession(ts.URL).emailStatus(context.Background(), apiAcct{Seg: "acct"}, "app")
77 if err == nil {
78 t.Fatal("emailStatus: want an error on 404")
79 }
80 if !isEmailNotDeclared(err) {
81 t.Fatalf("isEmailNotDeclared(%v) = false, want true", err)
82 }
83}
84
85func TestAPISessionEmailDomainEnsureWireShape(t *testing.T) {
86 var gotBody struct {
87 Domain string `json:"domain"`
88 }
89 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 if r.URL.Path != "/api/cli/apps/acct/app/email/domains" || r.Method != http.MethodPost {
91 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
92 }
93 json.NewDecoder(r.Body).Decode(&gotBody)
94 json.NewEncoder(w).Encode(map[string]any{
95 "domain": map[string]any{"domain": gotBody.Domain, "kind": "custom", "regions": map[string]any{}},
96 "pending": []map[string]any{{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300}},
97 })
98 }))
99 defer ts.Close()
100
101 res, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
102 if err != nil {
103 t.Fatalf("emailDomainEnsure: %v", err)
104 }
105 if gotBody.Domain != "x.com" {
106 t.Fatalf("sent domain = %q", gotBody.Domain)
107 }
108 if len(res.Pending) != 1 || res.Pending[0].Name != "_dkim1._domainkey.x.com" {
109 t.Fatalf("pending = %+v", res.Pending)
110 }
111}
112
113// TestAPISessionEmailDomainEnsureConflictIsPermanent pins the 409-preserving
114// behaviour: a claimed domain must decode into an apiMailError whose status
115// is Conflict, so the caller can tell it apart from a transient failure.
116func TestAPISessionEmailDomainEnsureConflictIsPermanent(t *testing.T) {
117 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118 w.WriteHeader(http.StatusConflict)
119 json.NewEncoder(w).Encode(map[string]any{"error": "sending domain unavailable"})
120 }))
121 defer ts.Close()
122
123 _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
124 var me *apiMailError
125 if !errors.As(err, &me) {
126 t.Fatalf("err = %v, want *apiMailError", err)
127 }
128 if !me.permanent() {
129 t.Fatal("409 claimed-domain error must report permanent() == true")
130 }
131}
132
133// TestAPISessionEmailDomainEnsureBadRequestIsPermanent is the sibling for
134// ErrNotOwnPlatformName's 400.
135func TestAPISessionEmailDomainEnsureBadRequestIsPermanent(t *testing.T) {
136 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137 w.WriteHeader(http.StatusBadRequest)
138 json.NewEncoder(w).Encode(map[string]any{"error": `"x.aaa.oncarlos.com" is not a platform name this account owns`})
139 }))
140 defer ts.Close()
141
142 _, err := newTestSession(ts.URL).emailDomainEnsure(context.Background(), apiAcct{Seg: "acct"}, "app", "x.aaa.oncarlos.com")
143 var me *apiMailError
144 if !errors.As(err, &me) || !me.permanent() {
145 t.Fatalf("err = %v, want a permanent apiMailError", err)
146 }
147}
148
149func TestAPISessionEmailDomainVerifyWireShape(t *testing.T) {
150 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151 if r.URL.Path != "/api/cli/apps/acct/app/email/domains/x.com/verify" || r.Method != http.MethodPost {
152 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
153 }
154 json.NewEncoder(w).Encode(map[string]any{
155 "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": "verified"}}},
156 })
157 }))
158 defer ts.Close()
159
160 res, err := newTestSession(ts.URL).emailDomainVerify(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
161 if err != nil {
162 t.Fatalf("emailDomainVerify: %v", err)
163 }
164 if domainStatus(res.Domain) != "verified" {
165 t.Fatalf("domainStatus = %q", domainStatus(res.Domain))
166 }
167}
168
169func TestAPISessionEmailDomainRemoveWireShape(t *testing.T) {
170 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171 if r.URL.Path != "/api/cli/apps/acct/app/email/domains/x.com" || r.Method != http.MethodDelete {
172 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
173 }
174 json.NewEncoder(w).Encode(map[string]any{
175 "app": "app", "domain": "x.com", "kind": "custom", "torn_down": true,
176 "orphaned": []map[string]any{
177 {"name": "tok._domainkey.x.com", "type": "CNAME", "values": []string{"tok.dkim.amazonses.com"}, "ttl": 300},
178 },
179 })
180 }))
181 defer ts.Close()
182
183 res, err := newTestSession(ts.URL).emailDomainRemove(context.Background(), apiAcct{Seg: "acct"}, "app", "x.com")
184 if err != nil {
185 t.Fatalf("emailDomainRemove: %v", err)
186 }
187 if !res.TornDown || len(res.Orphaned) != 1 {
188 t.Fatalf("reply = %+v, want a torn-down domain with one orphaned record", res)
189 }
190}
191
192// TestPrintDomainRemovalSaysWhichOfTheThreeThingsHappened: the outcomes are
193// not interchangeable — a sibling app kept the domain alive (nothing was
194// released), this app was the last one (the claim is free for any account),
195// or only a stranded claim existed to sweep. Printing the same line for all
196// three would tell a member nothing about whether the domain is now
197// claimable.
198func TestPrintDomainRemovalSaysWhichOfTheThreeThingsHappened(t *testing.T) {
199 render := func(res apiEmailDomainRemoveReply) string {
200 var buf bytes.Buffer
201 printDomainRemoval(&buf, res)
202 return buf.String()
203 }
204
205 kept := render(apiEmailDomainRemoveReply{
206 App: "jam", Domain: "jelly.tito.io", Kind: "custom", RemainingApps: []string{"stripe"},
207 })
208 if !strings.Contains(kept, "stripe") {
209 t.Fatalf("a kept domain does not name the app still using it:\n%s", kept)
210 }
211 if strings.Contains(kept, "claimable") {
212 t.Fatalf("a kept domain claims to have been released:\n%s", kept)
213 }
214
215 torn := render(apiEmailDomainRemoveReply{
216 App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true,
217 })
218 if !strings.Contains(torn, "claimable again") {
219 t.Fatalf("a released domain does not say it is claimable again:\n%s", torn)
220 }
221
222 swept := render(apiEmailDomainRemoveReply{
223 App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true, ClaimOnly: true,
224 })
225 if !strings.Contains(swept, "no domain record") {
226 t.Fatalf("an orphaned-claim sweep does not say what it swept:\n%s", swept)
227 }
228}
229
230// TestPrintDomainRemovalSeparatesDeadDKIMFromDurableRecords: of the six
231// records in a sending domain's set, only the three DKIM CNAMEs are derived
232// from the identity's own tokens, so only they are dead once it is gone.
233// The MAIL FROM MX, its SPF TXT and the DMARC TXT are deterministic
234// functions of the domain and region — telling an operator to rip those out
235// would cost them a second publish for nothing.
236func TestPrintDomainRemovalSeparatesDeadDKIMFromDurableRecords(t *testing.T) {
237 var buf bytes.Buffer
238 printDomainRemoval(&buf, apiEmailDomainRemoveReply{
239 App: "jam", Domain: "jelly.tito.io", Kind: "custom", TornDown: true,
240 Orphaned: []apiDNSRecord{
241 {Name: "tok1._domainkey.jelly.tito.io", Type: "CNAME", Values: []string{"tok1.dkim.amazonses.com"}},
242 {Name: "mail-eu-west-1.jelly.tito.io", Type: "MX", Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}},
243 {Name: "_dmarc.jelly.tito.io", Type: "TXT", Values: []string{"v=DMARC1; p=none"}},
244 },
245 })
246 out := buf.String()
247
248 deadAt := strings.Index(out, "tok1._domainkey.jelly.tito.io")
249 durableAt := strings.Index(out, "mail-eu-west-1.jelly.tito.io")
250 if deadAt < 0 || durableAt < 0 {
251 t.Fatalf("the orphaned records were not printed at all:\n%s", out)
252 }
253 if !strings.Contains(out, "now dead") || !strings.Contains(out, "still correct") {
254 t.Fatalf("output does not separate the dead records from the durable ones:\n%s", out)
255 }
256 if deadAt > durableAt {
257 t.Fatalf("the dead records must come first — they are the ones to remove:\n%s", out)
258 }
259}
260
261func TestAPISessionEmailEnableWireShape(t *testing.T) {
262 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
263 if r.URL.Path != "/api/cli/apps/acct/app/email/enable" || r.Method != http.MethodPost {
264 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
265 }
266 json.NewEncoder(w).Encode(map[string]any{
267 "app": "app", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"},
268 "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true},
269 })
270 }))
271 defer ts.Close()
272
273 res, err := newTestSession(ts.URL).emailEnable(context.Background(), apiAcct{Seg: "acct"}, "app")
274 if err != nil {
275 t.Fatalf("emailEnable: %v", err)
276 }
277 if len(res.EnvNames) != 5 {
278 t.Fatalf("env_names = %v", res.EnvNames)
279 }
280}
281
282func TestAPISessionEmailRotateFinishQuery(t *testing.T) {
283 var gotQuery string
284 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 gotQuery = r.URL.RawQuery
286 json.NewEncoder(w).Encode(map[string]any{"app": "app"})
287 }))
288 defer ts.Close()
289
290 if _, err := newTestSession(ts.URL).emailRotate(context.Background(), apiAcct{Seg: "acct"}, "app", true); err != nil {
291 t.Fatalf("emailRotate finish: %v", err)
292 }
293 if gotQuery != "finish=1" {
294 t.Fatalf("query = %q, want finish=1", gotQuery)
295 }
296}
297
298func TestAPISessionEmailCredentialsCreateWireShape(t *testing.T) {
299 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
300 if r.URL.Path != "/api/cli/apps/acct/app/email/credentials" || r.Method != http.MethodPost {
301 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
302 }
303 json.NewEncoder(w).Encode(map[string]any{
304 "app": "app", "name": "laptop", "host": "email-smtp.eu-west-1.amazonaws.com", "port": "587",
305 "username": "AKIA2", "password": "sekret", "from": "noreply@x.com",
306 })
307 }))
308 defer ts.Close()
309
310 res, err := newTestSession(ts.URL).emailCredentialsCreate(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop")
311 if err != nil {
312 t.Fatalf("emailCredentialsCreate: %v", err)
313 }
314 if res.Password != "sekret" {
315 t.Fatalf("password = %q", res.Password)
316 }
317}
318
319func TestAPISessionEmailCredentialsRevokeWireShape(t *testing.T) {
320 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
321 if r.URL.Path != "/api/cli/apps/acct/app/email/credentials/laptop" || r.Method != http.MethodDelete {
322 t.Errorf("path/method = %s %s", r.Method, r.URL.Path)
323 }
324 json.NewEncoder(w).Encode(map[string]any{"app": "app", "name": "laptop", "revoked": true})
325 }))
326 defer ts.Close()
327
328 res, err := newTestSession(ts.URL).emailCredentialsRevoke(context.Background(), apiAcct{Seg: "acct"}, "app", "laptop")
329 if err != nil {
330 t.Fatalf("emailCredentialsRevoke: %v", err)
331 }
332 if !res.Revoked {
333 t.Fatal("revoked = false")
334 }
335}
336
337func TestAPISessionEmailPauseWireShape(t *testing.T) {
338 var gotBody struct {
339 Paused *bool `json:"paused"`
340 }
341 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342 if r.URL.Path != "/api/cli/apps/acct/app/email/pause" {
343 t.Errorf("path = %s", r.URL.Path)
344 }
345 json.NewDecoder(r.Body).Decode(&gotBody)
346 json.NewEncoder(w).Encode(map[string]any{"app": "app", "active": false})
347 }))
348 defer ts.Close()
349
350 res, err := newTestSession(ts.URL).emailPause(context.Background(), apiAcct{Seg: "acct"}, "app", true)
351 if err != nil {
352 t.Fatalf("emailPause: %v", err)
353 }
354 if gotBody.Paused == nil || !*gotBody.Paused {
355 t.Fatalf("paused sent = %v, want true", gotBody.Paused)
356 }
357 if res.Active {
358 t.Fatal("active = true after a pause")
359 }
360}
361
362// ---- print* honesty rules ---------------------------------------------------
363
364// TestPrintPendingRecordsIsPasteable pins the required shape: one record per
365// line, name/type/value, never a JSON blob a member would have to parse
366// before they could paste it into a zone editor.
367func TestPrintPendingRecordsIsPasteable(t *testing.T) {
368 var buf bytes.Buffer
369 printPendingRecords(&buf, []apiDNSRecord{
370 {Name: "_dkim1._domainkey.x.com", Type: "CNAME", Values: []string{"a.dkim.amazonses.com"}},
371 {Name: "x.com", Type: "MX", Values: []string{"10 feedback-smtp.eu-west-1.amazonses.com"}},
372 {Name: "_dmarc.x.com", Type: "TXT", Values: []string{"v=DMARC1; p=none"}},
373 })
374 out := buf.String()
375
376 if strings.Contains(out, "{") || strings.Contains(out, "}") || strings.Contains(out, "[") {
377 t.Fatalf("pending records output looks like JSON, not pasteable lines:\n%s", out)
378 }
379 lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
380 if len(lines) != 3 {
381 t.Fatalf("got %d lines, want one per record:\n%s", len(lines), out)
382 }
383 for i, want := range []string{"_dkim1._domainkey.x.com", "x.com", "_dmarc.x.com"} {
384 if !strings.Contains(lines[i], want) {
385 t.Fatalf("line %d = %q, want it to name %q", i, lines[i], want)
386 }
387 }
388 if !strings.Contains(lines[0], "CNAME") || !strings.Contains(lines[0], "a.dkim.amazonses.com") {
389 t.Fatalf("line 0 = %q, want name, type AND value together on one line", lines[0])
390 }
391}
392
393// TestStandalonePrintsThePasswordOnce pins the required rule: the password
394// appears exactly once, on stdout, and the output as a whole never suggests
395// it will be shown again — the mint door's own doc comment says it will not.
396func TestStandalonePrintsThePasswordOnce(t *testing.T) {
397 var buf bytes.Buffer
398 printEmailCredentialsCreate(&buf, apiEmailCredsReply{
399 App: "harbourmail", Name: "laptop", Host: "email-smtp.eu-west-1.amazonaws.com",
400 Port: "587", Username: "AKIA2", Password: "swordfish-secret", From: "noreply@x.com",
401 })
402 out := buf.String()
403
404 if n := strings.Count(out, "swordfish-secret"); n != 1 {
405 t.Fatalf("password appears %d times, want exactly 1:\n%s", n, out)
406 }
407 if !strings.Contains(out, "shown once") && !strings.Contains(out, "not show it again") {
408 t.Fatalf("output does not warn this is a one-time display:\n%s", out)
409 }
410}
411
412// TestStandalonePasswordNeverReachesTheLogger closes the gap review found: a
413// future accidental `log.Printf` of the password inside
414// printEmailCredentialsCreate would pass every existing assertion, because
415// TestStandalonePrintsThePasswordOnce only inspects the io.Writer it was
416// given, never the standard logger. A credential leaked into a log has no
417// revocation story (it is the one door in this whole family that carries a
418// secret at all — printEmailCredentialsCreate's own doc comment), so this
419// asserts on the ACTUAL password value in BOTH places: present, once, in the
420// intended writer, and absent from the logger's own output entirely.
421func TestStandalonePasswordNeverReachesTheLogger(t *testing.T) {
422 var logBuf bytes.Buffer
423 origOut := log.Writer()
424 origFlags := log.Flags()
425 log.SetOutput(&logBuf)
426 defer func() {
427 log.SetOutput(origOut)
428 log.SetFlags(origFlags)
429 }()
430
431 var out bytes.Buffer
432 printEmailCredentialsCreate(&out, apiEmailCredsReply{
433 App: "harbourmail", Name: "laptop", Host: "email-smtp.eu-west-1.amazonaws.com",
434 Port: "587", Username: "AKIA2", Password: "swordfish-secret", From: "noreply@x.com",
435 })
436
437 if n := strings.Count(out.String(), "swordfish-secret"); n != 1 {
438 t.Fatalf("intended writer: password appears %d times, want exactly 1:\n%s", n, out.String())
439 }
440 if strings.Contains(logBuf.String(), "swordfish-secret") {
441 t.Fatalf("the password reached the standard logger — a leak with no revocation story:\n%s", logBuf.String())
442 }
443}
444
445// TestPrintEmailCredentialsListNeverShowsAPassword: the list view reads off
446// the status reply, whose Standalone entries carry no password field at
447// all — this pins that the print function does not somehow invent one, and
448// that the table is readable (names present).
449func TestPrintEmailCredentialsListNeverShowsAPassword(t *testing.T) {
450 var buf bytes.Buffer
451 printEmailCredentialsList(&buf, []apiEmailStandalone{
452 {Name: "laptop", IAMUser: "u1", KeyID: "AKIA2", By: "a@x.com", At: time.Date(2026, 8, 19, 0, 0, 0, 0, time.UTC)},
453 })
454 out := buf.String()
455 if !strings.Contains(out, "laptop") || !strings.Contains(out, "AKIA2") {
456 t.Fatalf("list output missing the credential's own fields:\n%s", out)
457 }
458 if strings.Contains(strings.ToLower(out), "password") {
459 t.Fatalf("list output mentions a password at all:\n%s", out)
460 }
461}
462
463func TestPrintEmailEnabledListsDeliveredVars(t *testing.T) {
464 var buf bytes.Buffer
465 printEmailEnabled(&buf, apiEmailReply{App: "harbourmail", EnvNames: []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PASS"}})
466 out := buf.String()
467 if !strings.Contains(out, "CARLOS_SMTP_HOST") || !strings.Contains(out, "CARLOS_SMTP_PASS") {
468 t.Fatalf("enabled output missing the delivered variable names:\n%s", out)
469 }
470}
471
472// TestPrintEmailStatusPausedNamesEvidenceNotAGuess: a paused app must be
473// called out loudly, with the counts a member CAN see — never an invented
474// reason the reply does not actually carry.
475func TestPrintEmailStatusPausedNamesEvidenceNotAGuess(t *testing.T) {
476 var buf bytes.Buffer
477 printEmailStatus(&buf, apiEmailReply{
478 App: "harbourmail", DailyCap: 50, Active: false,
479 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
480 Counts: apiEmailCounts{Sends: 51, Bounces: 2},
481 })
482 out := buf.String()
483 if !strings.Contains(strings.ToUpper(out), "PAUSED") {
484 t.Fatalf("status output does not call out the pause:\n%s", out)
485 }
486 if !strings.Contains(out, "51") || !strings.Contains(out, "50") {
487 t.Fatalf("status output missing the counts/cap evidence:\n%s", out)
488 }
489}
490
491// TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb (I3, review
492// follow-up): `carlos email resume` is operator-only and 404s for an
493// ordinary member — a cap-paused member must be told the pause clears
494// itself, never sent at a verb that will refuse them.
495func TestPrintEmailStatusCapPausePointsAtDayRollNotResumeVerb(t *testing.T) {
496 var buf bytes.Buffer
497 printEmailStatus(&buf, apiEmailReply{
498 App: "harbourmail", DailyCap: 50, Active: false,
499 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "cap"},
500 Counts: apiEmailCounts{Sends: 51},
501 })
502 out := buf.String()
503 if !strings.Contains(out, "UTC day roll") {
504 t.Fatalf("cap-pause status does not mention the day roll:\n%s", out)
505 }
506 if strings.Contains(out, "carlos email resume") {
507 t.Fatalf("cap-pause status still points an ordinary member at the operator-only resume verb:\n%s", out)
508 }
509}
510
511// TestPrintEmailStatusRatePausePointsAtOperator: the symmetric case — a
512// rate pause genuinely needs an operator, and the status output must say
513// so rather than suggesting the pause clears on its own.
514func TestPrintEmailStatusRatePausePointsAtOperator(t *testing.T) {
515 var buf bytes.Buffer
516 printEmailStatus(&buf, apiEmailReply{
517 App: "harbourmail", DailyCap: 100000, Active: false,
518 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1", PauseKind: "rate"},
519 Counts: apiEmailCounts{Sends: 1000, Bounces: 60},
520 })
521 out := buf.String()
522 if !strings.Contains(out, "operator") {
523 t.Fatalf("rate-pause status does not point at an operator:\n%s", out)
524 }
525 if strings.Contains(out, "UTC day roll") {
526 t.Fatalf("rate-pause status wrongly claims it clears on its own:\n%s", out)
527 }
528}
529
530func TestPrintEmailStatusActiveShowsKey(t *testing.T) {
531 var buf bytes.Buffer
532 printEmailStatus(&buf, apiEmailReply{
533 App: "harbourmail", DailyCap: 1000, Active: true,
534 Provisioned: &apiEmailProvisioned{KeyID: "AKIA9"},
535 })
536 out := buf.String()
537 if !strings.Contains(out, "active") || !strings.Contains(out, "AKIA9") {
538 t.Fatalf("status output missing the active/key line:\n%s", out)
539 }
540}
541
542func TestPrintEmailRotateNamesFinishCommand(t *testing.T) {
543 var buf bytes.Buffer
544 printEmailRotate(&buf, apiEmailReply{App: "app", Provisioned: &apiEmailProvisioned{KeyID: "AKIA2"}}, false)
545 out := buf.String()
546 if !strings.Contains(out, "AKIA2") || !strings.Contains(out, "carlos email rotate --finish --app app") {
547 t.Fatalf("rotate output = %q", out)
548 }
549}
550
551// ---- describeDomainEnsureErr: permanent refusals never suggest a retry ----
552
553// TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry is the
554// mutation-tested rule directly: a 409 (claimed) or 400 (not this account's
555// platform name) must never contain retry language.
556func TestDescribeDomainEnsureErrPermanentRefusalsNeverSuggestRetry(t *testing.T) {
557 for _, status := range []int{http.StatusConflict, http.StatusBadRequest} {
558 err := &apiMailError{status: status, err: errors.New("refused")}
559 got := describeDomainEnsureErr("x.com", err).Error()
560 for _, bad := range []string{"try again", "retry", "re-run", "resume"} {
561 if strings.Contains(strings.ToLower(got), bad) {
562 t.Fatalf("status %d: refusal text %q suggests a retry via %q", status, got, bad)
563 }
564 }
565 if !strings.Contains(got, "x.com") {
566 t.Fatalf("status %d: refusal text %q does not name the domain", status, got)
567 }
568 }
569}
570
571func TestDescribeDomainEnsureErrTransientPassesThrough(t *testing.T) {
572 err := &apiMailError{status: http.StatusBadGateway, err: errors.New("could not ensure the sending domain — nothing unrecorded was left behind; re-run to resume")}
573 got := describeDomainEnsureErr("x.com", err).Error()
574 if !strings.Contains(got, "re-run") {
575 t.Fatalf("transient refusal lost its own retry text: %q", got)
576 }
577}
578
579// ---- waitForDomainVerified: the timeout that must exit non-zero -----------
580
581// TestWaitForDomainVerifiedSucceeds: a domain that flips to verified before
582// the deadline returns nil.
583func TestWaitForDomainVerifiedSucceeds(t *testing.T) {
584 calls := 0
585 verify := func(context.Context) (apiEmailDomain, error) {
586 calls++
587 status := "pending"
588 if calls >= 2 {
589 status = "verified"
590 }
591 return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: status}}}, nil
592 }
593 if err := waitForDomainVerified(context.Background(), verify, "x.com", 2*time.Second); err != nil {
594 t.Fatalf("waitForDomainVerified: %v", err)
595 }
596 if calls < 2 {
597 t.Fatalf("calls = %d, want at least 2 (the loop must actually poll)", calls)
598 }
599}
600
601func TestWaitForDomainVerifiedFailsOnSESFailedStatus(t *testing.T) {
602 verify := func(context.Context) (apiEmailDomain, error) {
603 return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "failed"}}}, nil
604 }
605 err := waitForDomainVerified(context.Background(), verify, "x.com", time.Minute)
606 if err == nil {
607 t.Fatal("want a non-nil error on an SES-failed domain")
608 }
609 if !strings.Contains(err.Error(), "FAILED") {
610 t.Fatalf("error = %q, want it to say the verification FAILED", err)
611 }
612}
613
614// TestEnableTimeoutExitsNonZeroNamingTheAxis is the required test: a domain
615// that never leaves "pending" must make waitForDomainVerified return a
616// non-nil error (which the caller's log.Fatal turns into a non-zero exit —
617// every run* verb in this file follows that convention) that names the
618// candidate axes SES tracks, so a member reading it knows what to go check
619// instead of just "it didn't work."
620func TestEnableTimeoutExitsNonZeroNamingTheAxis(t *testing.T) {
621 verify := func(context.Context) (apiEmailDomain, error) {
622 return apiEmailDomain{Regions: map[string]*apiEmailRegionState{"eu-west-1": {Status: "pending"}}}, nil
623 }
624 start := time.Now()
625 err := waitForDomainVerified(context.Background(), verify, "harbourmail.aaa.oncarlos.com", 40*time.Millisecond)
626 elapsed := time.Since(start)
627
628 if err == nil {
629 t.Fatal("waitForDomainVerified on a domain stuck pending must return a non-nil error (non-zero exit) — got nil")
630 }
631 for _, axis := range []string{"DKIM", "MAIL FROM", "sending"} {
632 if !strings.Contains(err.Error(), axis) {
633 t.Fatalf("timeout error %q does not name axis %q", err.Error(), axis)
634 }
635 }
636 if !strings.Contains(err.Error(), "harbourmail.aaa.oncarlos.com") {
637 t.Fatalf("timeout error %q does not name the domain", err.Error())
638 }
639 if !strings.Contains(strings.ToLower(err.Error()), "timed out") {
640 t.Fatalf("timeout error %q does not say it timed out", err.Error())
641 }
642 if elapsed > 2*time.Second {
643 t.Fatalf("waitForDomainVerified took %s for a 40ms timeout — polling interval is not capped by the deadline", elapsed)
644 }
645}
646
647// ---- runEmailEnableFlow: the full member path against a fake console ------
648
649// fakeEmailConsole is a minimal in-process console answering every email
650// door with fixed, easily-asserted bodies. verifyCalls counts domain-verify
651// requests so the platform-domain-vs-custom-domain polling behaviour can be
652// pinned without a live SES estate.
653type fakeEmailConsole struct {
654 ts *httptest.Server
655 declared bool
656 declaredVia struct {
657 EnvPrefix, From string
658 }
659 pendingRecords bool
660 verifyCalls int
661 verifiedAfter int // domain reports verified once verifyCalls reaches this
662 enableCalled bool
663 domainConflict bool
664 credRevoked bool
665 lastRevokedName string
666 // defaultHost is the not-declared GET /email 404's default_host field
667 // (I4, review follow-up) — "" reproduces a deployment with no platform
668 // suffix configured.
669 defaultHost string
670}
671
672func newFakeEmailConsole(t *testing.T, verifiedAfter int) *fakeEmailConsole {
673 t.Helper()
674 fc := &fakeEmailConsole{verifiedAfter: verifiedAfter}
675 fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
676 switch {
677 case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps":
678 json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{
679 {"sqid": "aaa", "name": "ops", "role": "owner", "apps": []string{"harbourmail"}},
680 }})
681 case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/email"):
682 if !fc.declared {
683 w.WriteHeader(http.StatusNotFound)
684 json.NewEncoder(w).Encode(map[string]any{
685 "error": "no email is declared for this app — declare it first",
686 "default_host": fc.defaultHost,
687 })
688 return
689 }
690 json.NewEncoder(w).Encode(map[string]any{
691 "app": "harbourmail",
692 "declared": map[string]any{
693 "env_prefix": fc.declaredVia.EnvPrefix, "from": fc.declaredVia.From,
694 },
695 "standalone": []any{}, "domains": []any{}, "env_names": []string{},
696 })
697 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email"):
698 var body struct{ EnvPrefix, From string }
699 json.NewDecoder(r.Body).Decode(&body)
700 if body.From == "" {
701 w.WriteHeader(http.StatusBadRequest)
702 json.NewEncoder(w).Encode(map[string]any{"error": "From is not an address"})
703 return
704 }
705 fc.declared = true
706 fc.declaredVia.EnvPrefix, fc.declaredVia.From = body.EnvPrefix, body.From
707 json.NewEncoder(w).Encode(map[string]any{"app": "harbourmail", "declared": map[string]any{"env_prefix": body.EnvPrefix, "from": body.From}})
708 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/domains"):
709 if fc.domainConflict {
710 w.WriteHeader(http.StatusConflict)
711 json.NewEncoder(w).Encode(map[string]any{"error": "sending domain unavailable"})
712 return
713 }
714 pending := []map[string]any{}
715 if fc.pendingRecords {
716 pending = append(pending, map[string]any{"name": "_dkim1._domainkey.x.com", "type": "CNAME", "values": []string{"a.dkim.amazonses.com"}, "ttl": 300})
717 }
718 json.NewEncoder(w).Encode(map[string]any{
719 "domain": map[string]any{"domain": "x.com", "kind": "custom", "regions": map[string]any{}},
720 "pending": pending,
721 })
722 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/verify"):
723 fc.verifyCalls++
724 status := "pending"
725 if fc.verifyCalls >= fc.verifiedAfter {
726 status = "verified"
727 }
728 json.NewEncoder(w).Encode(map[string]any{
729 "domain": map[string]any{"domain": "x.com", "regions": map[string]any{"eu-west-1": map[string]any{"status": status}}},
730 })
731 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/enable"):
732 fc.enableCalled = true
733 json.NewEncoder(w).Encode(map[string]any{
734 "app": "harbourmail", "env_names": []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "CARLOS_SMTP_FROM"},
735 "provisioned": map[string]any{"iam_user": "u", "key_id": "AKIA1", "active": true},
736 })
737 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/email/credentials"):
738 json.NewEncoder(w).Encode(map[string]any{
739 "app": "harbourmail", "name": "cli-test", "host": "email-smtp.eu-west-1.amazonaws.com",
740 "port": "587", "username": "AKIA9", "password": "temp-secret", "from": "noreply@x.com",
741 })
742 case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/email/credentials/"):
743 fc.credRevoked = true
744 fc.lastRevokedName = strings.TrimPrefix(r.URL.Path, r.URL.Path[:strings.LastIndex(r.URL.Path, "/")+1])
745 json.NewEncoder(w).Encode(map[string]any{"app": "harbourmail", "name": fc.lastRevokedName, "revoked": true})
746 default:
747 w.WriteHeader(http.StatusNotFound)
748 }
749 }))
750 t.Cleanup(fc.ts.Close)
751 return fc
752}
753
754// TestRunEmailEnableFlowPlatformDomainNoPendingRecords: -domain omitted,
755// -from given, on a platform-kind domain the fake reports with nothing
756// pending — enable runs straight through and prints the delivered names.
757func TestRunEmailEnableFlowPlatformDomainNoPendingRecords(t *testing.T) {
758 fc := newFakeEmailConsole(t, 1) // verified on the first check
759 sess := newTestSession(fc.ts.URL)
760 var buf bytes.Buffer
761
762 err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail",
763 "", "noreply@harbourmail.aaa.oncarlos.com", "", 5*time.Second)
764 if err != nil {
765 t.Fatalf("runEmailEnableFlow: %v", err)
766 }
767 if !fc.enableCalled {
768 t.Fatal("enable door was never called")
769 }
770 out := buf.String()
771 if !strings.Contains(out, "CARLOS_SMTP_HOST") {
772 t.Fatalf("output missing delivered var names:\n%s", out)
773 }
774 if strings.Contains(out, "publish these records") {
775 t.Fatalf("platform domain with nothing pending must not print a record set:\n%s", out)
776 }
777}
778
779// TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls: a custom domain
780// with pending records prints them and polls verify more than once before
781// enabling.
782func TestRunEmailEnableFlowCustomDomainPrintsRecordsAndPolls(t *testing.T) {
783 fc := newFakeEmailConsole(t, 2) // verified on the second check
784 fc.pendingRecords = true
785 sess := newTestSession(fc.ts.URL)
786 var buf bytes.Buffer
787
788 err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail",
789 "", "", "x.com", 5*time.Second)
790 if err != nil {
791 t.Fatalf("runEmailEnableFlow: %v", err)
792 }
793 out := buf.String()
794 if !strings.Contains(out, "publish these records") || !strings.Contains(out, "_dkim1._domainkey.x.com") {
795 t.Fatalf("custom domain output missing the record set:\n%s", out)
796 }
797 if fc.verifyCalls < 2 {
798 t.Fatalf("verifyCalls = %d, want at least 2 (must actually poll, not accept the first pending answer)", fc.verifyCalls)
799 }
800 if fc.declaredVia.From != "noreply@x.com" {
801 t.Fatalf("From defaulted to %q, want noreply@x.com derived from --domain", fc.declaredVia.From)
802 }
803}
804
805// TestRunEmailEnableFlowClaimedDomainFailsPermanently: EnsureDomain's 409
806// must stop the flow with a permanent-refusal message, never reach enable.
807func TestRunEmailEnableFlowClaimedDomainFailsPermanently(t *testing.T) {
808 fc := newFakeEmailConsole(t, 1)
809 fc.domainConflict = true
810 sess := newTestSession(fc.ts.URL)
811 var buf bytes.Buffer
812
813 err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail",
814 "", "", "x.com", 5*time.Second)
815 if err == nil {
816 t.Fatal("want an error when the domain is already claimed")
817 }
818 if fc.enableCalled {
819 t.Fatal("enable must never be called after a claimed-domain refusal")
820 }
821 if strings.Contains(strings.ToLower(err.Error()), "retry") || strings.Contains(strings.ToLower(err.Error()), "try again") {
822 t.Fatalf("claimed-domain error suggests a retry: %q", err.Error())
823 }
824}
825
826func TestRunEmailEnableFlowNoFromNoDomainRefusesCleanly(t *testing.T) {
827 fc := newFakeEmailConsole(t, 1)
828 sess := newTestSession(fc.ts.URL)
829 var buf bytes.Buffer
830 err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "", time.Second)
831 if err == nil {
832 t.Fatal("want an error when neither --from nor --domain is given and nothing was declared before")
833 }
834}
835
836// TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost (I4,
837// review follow-up): with neither -from nor -domain given, and nothing
838// declared before, the console's own default_host (spec §3's default
839// identity, the app's own host) must drive both — the one-command story
840// `carlos email enable -app X` promises when the deployment has a
841// platform suffix configured.
842func TestRunEmailEnableFlowNoFromNoDomainDefaultsToTheAppsPlatformHost(t *testing.T) {
843 fc := newFakeEmailConsole(t, 1) // verified on the first check
844 fc.defaultHost = "harbourmail.aaa.oncarlos.com"
845 sess := newTestSession(fc.ts.URL)
846 var buf bytes.Buffer
847
848 err := runEmailEnableFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "", "", "", 5*time.Second)
849 if err != nil {
850 t.Fatalf("runEmailEnableFlow: %v", err)
851 }
852 if !fc.enableCalled {
853 t.Fatal("enable door was never called")
854 }
855 if fc.declaredVia.From != "noreply@harbourmail.aaa.oncarlos.com" {
856 t.Fatalf("From = %q, want noreply@ the console's default_host", fc.declaredVia.From)
857 }
858}
859
860// ---- runEmailTestFlow: mint / send / revoke bookkeeping --------------------
861
862// TestRunEmailTestFlowRevokesOnSuccess: the temporary credential is revoked
863// after a successful send.
864func TestRunEmailTestFlowRevokesOnSuccess(t *testing.T) {
865 fc := newFakeEmailConsole(t, 1)
866 sess := newTestSession(fc.ts.URL)
867 orig := sendTestMessageFn
868 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "accepted for delivery", nil }
869 t.Cleanup(func() { sendTestMessageFn = orig })
870
871 var buf bytes.Buffer
872 res, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com")
873 if err != nil {
874 t.Fatalf("runEmailTestFlow: %v", err)
875 }
876 if res.detail == "" {
877 t.Fatal("result detail is empty")
878 }
879 if !fc.credRevoked {
880 t.Fatal("the temporary standalone credential was never revoked")
881 }
882}
883
884// TestRunEmailTestFlowRevokesOnFailureToo: a send failure must not leave a
885// live credential behind either.
886func TestRunEmailTestFlowRevokesOnFailureToo(t *testing.T) {
887 fc := newFakeEmailConsole(t, 1)
888 sess := newTestSession(fc.ts.URL)
889 orig := sendTestMessageFn
890 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) { return "", fmt.Errorf("connection refused") }
891 t.Cleanup(func() { sendTestMessageFn = orig })
892
893 var buf bytes.Buffer
894 _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com")
895 if err == nil {
896 t.Fatal("want an error when the send itself fails")
897 }
898 if !fc.credRevoked {
899 t.Fatal("a failed send must still revoke the temporary credential")
900 }
901}
902
903// The test message is multipart/alternative with a plain and an HTML part,
904// and a real MIME parser has to read both back out of it. A malformed
905// boundary would still be "accepted for delivery" by SES and then render as
906// raw MIME in the recipient's client, which nothing else here would catch.
907func TestBuildTestMessageIsMultipartAlternative(t *testing.T) {
908 raw := buildTestMessage("noreply@example.com", "friend@example.com")
909 msg, err := mail.ReadMessage(bytes.NewReader(raw))
910 if err != nil {
911 t.Fatalf("not a parseable message: %v", err)
912 }
913 if got := msg.Header.Get("MIME-Version"); got != "1.0" {
914 t.Errorf("MIME-Version = %q, want 1.0", got)
915 }
916 mt, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
917 if err != nil || mt != "multipart/alternative" {
918 t.Fatalf("Content-Type = %q (%v), want multipart/alternative", msg.Header.Get("Content-Type"), err)
919 }
920 mr := multipart.NewReader(msg.Body, params["boundary"])
921 var types []string
922 for {
923 part, err := mr.NextPart()
924 if err == io.EOF {
925 break
926 }
927 if err != nil {
928 t.Fatalf("reading part %d: %v", len(types)+1, err)
929 }
930 pt, _, _ := mime.ParseMediaType(part.Header.Get("Content-Type"))
931 body, _ := io.ReadAll(part)
932 if !strings.Contains(string(body), "carlos email test") {
933 t.Errorf("%s part has no body text: %q", pt, body)
934 }
935 types = append(types, pt)
936 }
937 // Plain first: a client shows the LAST alternative it can render.
938 if strings.Join(types, ",") != "text/plain,text/html" {
939 t.Errorf("parts = %v, want [text/plain text/html]", types)
940 }
941 for _, line := range strings.Split(string(raw), "\r\n") {
942 if len(line) > 998 {
943 t.Errorf("line of %d bytes exceeds RFC 5322's 998", len(line))
944 }
945 }
946}
947
948// TestSendTestMessageMarksA535AsAnAuthFailure pins the link the whole #265
949// fix hangs from: the real sendTestMessage must return an error the retry
950// loop can RECOGNISE. The retry tests above substitute sendTestMessageFn, so
951// they cannot see this — mutation-testing them proved it, by reverting the
952// sentinel to a plain fmt.Errorf and watching every one of them stay green.
953//
954// So this one drives the real function against a fake SMTP server that
955// refuses AUTH with 535, exactly as SES does for a key it cannot see yet.
956// The server deliberately does NOT advertise STARTTLS: sendTestMessage only
957// upgrades when the extension is offered, and Go's PlainAuth permits
958// unencrypted credentials to a loopback host, which is what makes an
959// in-process server possible at all.
960func TestSendTestMessageMarksA535AsAnAuthFailure(t *testing.T) {
961 ln, err := net.Listen("tcp", "127.0.0.1:0")
962 if err != nil {
963 t.Fatalf("listen: %v", err)
964 }
965 defer ln.Close()
966 go func() {
967 c, err := ln.Accept()
968 if err != nil {
969 return
970 }
971 defer c.Close()
972 br := bufio.NewReader(c)
973 fmt.Fprint(c, "220 fake ESMTP\r\n")
974 for {
975 line, err := br.ReadString('\n')
976 if err != nil {
977 return
978 }
979 switch {
980 case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
981 fmt.Fprint(c, "250-fake\r\n250 AUTH PLAIN\r\n")
982 case strings.HasPrefix(line, "AUTH"):
983 fmt.Fprint(c, "535 Authentication Credentials Invalid\r\n")
984 case strings.HasPrefix(line, "QUIT"):
985 fmt.Fprint(c, "221 bye\r\n")
986 return
987 default:
988 fmt.Fprint(c, "250 ok\r\n")
989 }
990 }
991 }()
992
993 host, port, err := net.SplitHostPort(ln.Addr().String())
994 if err != nil {
995 t.Fatalf("split: %v", err)
996 }
997 creds := apiEmailCredsReply{
998 Host: host, Port: port,
999 Username: "AKIAEXAMPLE", Password: "not-the-right-one",
1000 From: "noreply@example.com",
1001 }
1002 _, err = sendTestMessage(creds, "friend@example.com")
1003 if err == nil {
1004 t.Fatal("want an error from a 535")
1005 }
1006 var authErr *smtpAuthError
1007 if !errors.As(err, &authErr) {
1008 t.Fatalf("a 535 must be recognisable as an auth failure, got %T: %v — "+
1009 "the retry loop keys on this, so an unmarked 535 makes carlos email test "+
1010 "fail on the first attempt again (#265)", err, err)
1011 }
1012}
1013
1014// TestRunEmailTestFlowRetriesTheAuthWindow is issue #265. SES's SMTP
1015// endpoint does not see a just-minted IAM access key for a minute or two, so
1016// the FIRST auth attempt against a fresh credential fails with 535. Because
1017// this flow mints a new credential every run, a human retrying the command
1018// never gets past it either — every retry is another zero-second-old key.
1019// The flow must therefore be patient itself.
1020func TestRunEmailTestFlowRetriesTheAuthWindow(t *testing.T) {
1021 fc := newFakeEmailConsole(t, 1)
1022 sess := newTestSession(fc.ts.URL)
1023 origSend, origSleep := sendTestMessageFn, sleepFn
1024 calls := 0
1025 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) {
1026 calls++
1027 if calls < 3 {
1028 return "", &smtpAuthError{fmt.Errorf("535 %q", "Authentication Credentials Invalid")}
1029 }
1030 return "accepted for delivery", nil
1031 }
1032 slept := 0
1033 sleepFn = func(context.Context, time.Duration) error { slept++; return nil }
1034 t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep })
1035
1036 var buf bytes.Buffer
1037 res, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com")
1038 if err != nil {
1039 t.Fatalf("runEmailTestFlow: %v", err)
1040 }
1041 if res.detail == "" {
1042 t.Fatal("result detail is empty")
1043 }
1044 if calls != 3 {
1045 t.Fatalf("send attempts = %d, want 3 (two refusals then success)", calls)
1046 }
1047 if slept != 2 {
1048 t.Fatalf("slept %d times, want 2 — one wait between each attempt", slept)
1049 }
1050 // The wait is long enough to look like a hang; saying so is part of the
1051 // fix, not decoration.
1052 if !strings.Contains(buf.String(), "waiting") {
1053 t.Fatalf("nothing told the user why it is waiting; output = %q", buf.String())
1054 }
1055 if !fc.credRevoked {
1056 t.Fatal("the temporary standalone credential was never revoked")
1057 }
1058}
1059
1060// TestRunEmailTestFlowGivesUpAndStillRevokes: patience is bounded. An auth
1061// failure that outlasts the window is a real failure and must surface as
1062// one — and the credential must still not be left behind.
1063func TestRunEmailTestFlowGivesUpAndStillRevokes(t *testing.T) {
1064 fc := newFakeEmailConsole(t, 1)
1065 sess := newTestSession(fc.ts.URL)
1066 origSend, origSleep := sendTestMessageFn, sleepFn
1067 calls := 0
1068 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) {
1069 calls++
1070 return "", &smtpAuthError{fmt.Errorf("535 %q", "Authentication Credentials Invalid")}
1071 }
1072 sleepFn = func(context.Context, time.Duration) error { return nil }
1073 t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep })
1074
1075 var buf bytes.Buffer
1076 _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com")
1077 if err == nil {
1078 t.Fatal("want an error when auth never succeeds")
1079 }
1080 if calls != emailTestAuthAttempts {
1081 t.Fatalf("send attempts = %d, want emailTestAuthAttempts = %d", calls, emailTestAuthAttempts)
1082 }
1083 if !fc.credRevoked {
1084 t.Fatal("giving up must still revoke the temporary credential")
1085 }
1086}
1087
1088// TestRunEmailTestFlowDoesNotRetryANonAuthFailure: the retry exists for one
1089// specific transient. A refused connection, or a message SES rejects, must
1090// fail on the FIRST attempt — retrying those for two minutes would turn a
1091// clear error into a hang.
1092func TestRunEmailTestFlowDoesNotRetryANonAuthFailure(t *testing.T) {
1093 fc := newFakeEmailConsole(t, 1)
1094 sess := newTestSession(fc.ts.URL)
1095 origSend, origSleep := sendTestMessageFn, sleepFn
1096 calls := 0
1097 sendTestMessageFn = func(apiEmailCredsReply, string) (string, error) {
1098 calls++
1099 return "", fmt.Errorf("connection refused")
1100 }
1101 sleepFn = func(context.Context, time.Duration) error { t.Fatal("slept on a non-auth failure"); return nil }
1102 t.Cleanup(func() { sendTestMessageFn, sleepFn = origSend, origSleep })
1103
1104 var buf bytes.Buffer
1105 if _, err := runEmailTestFlow(context.Background(), &buf, sess, apiAcct{Seg: "aaa"}, "harbourmail", "friend@gmail.com"); err == nil {
1106 t.Fatal("want an error")
1107 }
1108 if calls != 1 {
1109 t.Fatalf("send attempts = %d, want 1 — a non-auth failure is not retried", calls)
1110 }
1111}
1112
1113// ---- RunEmail-level: dispatch and refusals (real binary) -------------------
1114
1115// TestEmailVerbDispatchAndRefusals drives the real binary through
1116// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a
1117// logged-in laptop's real credential and console can never be what this
1118// test exercises: unknown sub-verb, missing -app, and direct bucket mode
1119// refused with email's own sentence. This also pins main.go's `case
1120// "email"` wiring end to end.
1121func TestEmailVerbDispatchAndRefusals(t *testing.T) {
1122 bin := buildCarlos(t)
1123 scratch := t.TempDir()
1124 run := func(env []string, args ...string) (string, error) {
1125 t.Helper()
1126 cmd := exec.Command(bin, args...)
1127 cmd.Env = carlosTestEnv(t, env...)
1128 cmd.Dir = scratch
1129 out, err := cmd.CombinedOutput()
1130 return string(out), err
1131 }
1132
1133 for _, args := range [][]string{{"email"}, {"email", "wat"}, {"email", "domains"}, {"email", "domains", "wat"}} {
1134 out, err := run(nil, args...)
1135 if err == nil {
1136 t.Fatalf("carlos %v exited 0:\n%s", args, out)
1137 }
1138 if !strings.Contains(out, "usage: carlos email <enable|domains|status|test|credentials|rotate|pause|resume>") {
1139 t.Fatalf("carlos %v = %q, want the email usage block", args, out)
1140 }
1141 }
1142
1143 for _, args := range [][]string{
1144 {"email", "enable", "-app", "keymail"},
1145 {"email", "status", "-app", "keymail"},
1146 {"email", "test", "-app", "keymail", "-to", "a@b.com"},
1147 {"email", "rotate", "-app", "keymail"},
1148 {"email", "pause", "-app", "keymail"},
1149 {"email", "resume", "-app", "keymail"},
1150 {"email", "credentials", "create", "-app", "keymail", "-name", "laptop"},
1151 {"email", "domains", "add", "-app", "keymail", "x.com"},
1152 {"email", "domains", "rm", "-app", "keymail", "x.com"},
1153 } {
1154 out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, args...)
1155 if err == nil {
1156 t.Fatalf("carlos %v in direct bucket mode exited 0:\n%s", args, out)
1157 }
1158 if !strings.Contains(out, "carlos email works through a console") || !strings.Contains(out, "carlos auth login") {
1159 t.Fatalf("carlos %v direct-mode refusal = %q, want email's own message and the login fix", args, out)
1160 }
1161 }
1162
1163 out, err := run(nil, "email", "status", "-app", "Not An App")
1164 if err == nil {
1165 t.Fatalf("email status with a bad app name exited 0:\n%s", out)
1166 }
1167 if !strings.Contains(out, "not a usable app name") {
1168 t.Fatalf("bad app name = %q, want the name gate", out)
1169 }
1170}
1171
1172// TestPrintEmailStatusNamesADomainWithNoEventDestination: a verified domain
1173// whose region carries no configuration set emits NO SES events at all, so
1174// the daily cap, the bounce/complaint thresholds and the automatic pause are
1175// all inert for it — and the "today: 0/50 sends" line reads exactly the same
1176// as a quiet day. Every identity verified before a deployment's
1177// CARLOS_MAIL_SNS_TOPIC_ARN was set is in that state permanently, because
1178// setting the variable binds nothing retroactively. The record already
1179// carries config_set and the status door already returns it; this is the one
1180// view a member with no AWS console has, so it has to say so, and name the
1181// verb that repairs it.
1182func TestPrintEmailStatusNamesADomainWithNoEventDestination(t *testing.T) {
1183 var buf bytes.Buffer
1184 printEmailStatus(&buf, apiEmailReply{
1185 App: "harbourmail", DailyCap: 50, Active: true,
1186 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
1187 Domains: []apiEmailDomain{{
1188 Domain: "harbourmail.bag.oncarlos.com", Kind: "platform",
1189 Regions: map[string]*apiEmailRegionState{
1190 "eu-west-1": {Status: "verified", ConfigSet: ""},
1191 },
1192 }},
1193 })
1194 out := buf.String()
1195 if !strings.Contains(out, "no SES event destination") {
1196 t.Fatalf("status output does not say the domain emits no events:\n%s", out)
1197 }
1198 // The consequence, not just the condition: a member reading "no event
1199 // destination" learns nothing unless it also says what stops working.
1200 if !strings.Contains(out, "cap") || !strings.Contains(out, "pause") {
1201 t.Fatalf("status output does not name what is inert without events:\n%s", out)
1202 }
1203 // The no-infra rule (CLAUDE.md): the fix must be a carlos verb, never
1204 // "go look in the AWS console".
1205 if !strings.Contains(out, "carlos email domains add --app harbourmail harbourmail.bag.oncarlos.com") {
1206 t.Fatalf("status output does not name the verb that repairs it:\n%s", out)
1207 }
1208}
1209
1210// TestPrintEmailStatusStaysQuietWhenEventsAreBound: the symmetric case. A
1211// domain bound to a configuration set is the healthy shape, and the status
1212// view must not nag about it — a warning printed on every healthy domain is
1213// a warning nobody reads on the unhealthy one.
1214func TestPrintEmailStatusStaysQuietWhenEventsAreBound(t *testing.T) {
1215 var buf bytes.Buffer
1216 printEmailStatus(&buf, apiEmailReply{
1217 App: "harbourmail", DailyCap: 50, Active: true,
1218 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
1219 Domains: []apiEmailDomain{{
1220 Domain: "harbourmail.bag.oncarlos.com", Kind: "platform",
1221 Regions: map[string]*apiEmailRegionState{
1222 "eu-west-1": {Status: "verified", ConfigSet: "carlos-bag"},
1223 },
1224 }},
1225 })
1226 out := buf.String()
1227 if strings.Contains(out, "no SES event destination") {
1228 t.Fatalf("status output warns about a domain that IS bound to a config set:\n%s", out)
1229 }
1230}
1231
1232// TestPrintEmailStatusDoesNotWarnOnAnUnverifiedDomain: a pending or failed
1233// region has no configuration set yet either, but the reason is that it is
1234// not finished verifying — telling a member their events are broken while
1235// the identity is still converging is noise on top of a state they are
1236// already being told about.
1237func TestPrintEmailStatusDoesNotWarnOnAnUnverifiedDomain(t *testing.T) {
1238 var buf bytes.Buffer
1239 printEmailStatus(&buf, apiEmailReply{
1240 App: "harbourmail", DailyCap: 50,
1241 Domains: []apiEmailDomain{{
1242 Domain: "harbourmail.bag.oncarlos.com", Kind: "platform",
1243 Regions: map[string]*apiEmailRegionState{
1244 "eu-west-1": {Status: "pending", ConfigSet: ""},
1245 },
1246 }},
1247 })
1248 out := buf.String()
1249 if strings.Contains(out, "no SES event destination") {
1250 t.Fatalf("status output warns about events on a still-verifying domain:\n%s", out)
1251 }
1252}
1253
1254// `carlos email events` puts what happened first and what the receiving
1255// server said beneath it — the second line is what tells a member whether
1256// a bounce was a typo in the address or a full mailbox.
1257func TestPrintEmailEventsSaysWhatHappenedAndWhy(t *testing.T) {
1258 var buf bytes.Buffer
1259 at := time.Date(2026, 9, 15, 8, 0, 2, 0, time.UTC)
1260 printEmailEvents(&buf, "jb-book", apiEmailEventsReply{RetentionDays: 30, Events: []apiEmailEvent{
1261 {MessageID: "0102-ann", Type: "Bounce", Recipient: "ann@example.com", Status: "5.1.1",
1262 Diagnostic: "smtp; 550 5.1.1 Recipient address rejected", Detail: "Permanent/NoEmail", At: at},
1263 {MessageID: "0102-bob", Type: "Delivery", Recipient: "bob@example.com", Diagnostic: "250 2.0.0 OK", At: at.Add(-time.Minute)},
1264 }})
1265 out := buf.String()
1266 for _, want := range []string{
1267 "bounced (Permanent/NoEmail)", "ann@example.com", "0102-ann", "Recipient address rejected",
1268 "delivered", "bob@example.com", "250 2.0.0 OK",
1269 "2 event(s)", "30 days",
1270 } {
1271 if !strings.Contains(out, want) {
1272 t.Errorf("output is missing %q:\n%s", want, out)
1273 }
1274 }
1275 if strings.Index(out, "0102-ann") > strings.Index(out, "0102-bob") {
1276 t.Errorf("events should print in the order the console sent them, newest first:\n%s", out)
1277 }
1278 // The status code is already in the diagnostic here; it is not said twice.
1279 if strings.Count(out, "5.1.1") != 1 {
1280 t.Errorf("the status code was repeated:\n%s", out)
1281 }
1282
1283 buf.Reset()
1284 printEmailEvents(&buf, "jb-book", apiEmailEventsReply{RetentionDays: 30})
1285 if !strings.Contains(buf.String(), "no email events for jb-book") {
1286 t.Errorf("an empty log should say so, and why it might be empty:\n%s", buf.String())
1287 }
1288}
1289
1290func TestSinceParamTakesADurationOrATime(t *testing.T) {
1291 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
1292 for _, c := range []struct{ in, want string }{
1293 {"", ""},
1294 {"24h", "2026-09-14T12:00:00Z"},
1295 {"90m", "2026-09-15T10:30:00Z"},
1296 {"2026-09-10T08:00:00+01:00", "2026-09-10T07:00:00Z"},
1297 } {
1298 got, err := sinceParam(c.in, now)
1299 if err != nil || got != c.want {
1300 t.Errorf("sinceParam(%q) = %q, %v; want %q", c.in, got, err, c.want)
1301 }
1302 }
1303 if _, err := sinceParam("yesterday", now); err == nil {
1304 t.Error("a word that is neither should be refused, not sent to the console")
1305 }
1306}
1307
1308// `carlos email events push` says where events go and what the app checks
1309// them with, and when delivery is failing says so in terms that point at
1310// the fix: nothing at the path yet, or a gate in the way.
1311func TestPrintEmailPushPointsAtTheFix(t *testing.T) {
1312 var buf bytes.Buffer
1313 printEmailPush(&buf, "jb-book", apiEmailPushReply{}, false)
1314 if !strings.Contains(buf.String(), "not told") || !strings.Contains(buf.String(), "--path /mail/events") {
1315 t.Errorf("off should say how to turn it on:\n%s", buf.String())
1316 }
1317
1318 on := apiEmailPushReply{On: true, Path: "/mail/events", URL: "https://jb-book.beb.oncarlos.com/mail/events",
1319 KeyEnv: "CARLOS_MAIL_EVENTS_KEY", Status: apiEmailPushDelivery{Delivered: 3, Pending: 1}}
1320 buf.Reset()
1321 printEmailPush(&buf, "jb-book", on, true)
1322 out := buf.String()
1323 for _, want := range []string{"https://jb-book.beb.oncarlos.com/mail/events", "CARLOS_MAIL_EVENTS_KEY", "carlos restart --app jb-book", "3 delivered, 1 waiting"} {
1324 if !strings.Contains(out, want) {
1325 t.Errorf("missing %q:\n%s", want, out)
1326 }
1327 }
1328
1329 delivered := time.Date(2026, 9, 15, 8, 0, 0, 0, time.UTC)
1330 for status, hint := range map[int]string{404: "nothing at /mail/events", 401: "carlos gate"} {
1331 failing := on
1332 failing.Status = apiEmailPushDelivery{Pending: 1, LastDelivered: delivered, LastFailedAt: delivered.Add(time.Hour),
1333 LastStatus: status, LastError: fmt.Sprintf("answered %d", status)}
1334 buf.Reset()
1335 printEmailPush(&buf, "jb-book", failing, false)
1336 if !strings.Contains(buf.String(), fmt.Sprintf("answered %d", status)) || !strings.Contains(buf.String(), hint) {
1337 t.Errorf("a %d should point at %q:\n%s", status, hint, buf.String())
1338 }
1339 if strings.Contains(buf.String(), "restart") {
1340 t.Errorf("the restart hint belongs to turning it on, not to every look:\n%s", buf.String())
1341 }
1342 }
1343
1344 // A failure since recovered from is not news.
1345 recovered := on
1346 recovered.Status = apiEmailPushDelivery{Delivered: 4, LastDelivered: delivered.Add(2 * time.Hour),
1347 LastFailedAt: delivered, LastStatus: 404, LastError: "answered 404"}
1348 buf.Reset()
1349 printEmailPush(&buf, "jb-book", recovered, false)
1350 if strings.Contains(buf.String(), "failed") {
1351 t.Errorf("an old failure was reported after a later delivery landed:\n%s", buf.String())
1352 }
1353}
1354
1355// A suspended push says so first, says why, and says how to turn it back on.
1356func TestPrintEmailPushSaysItIsSuspended(t *testing.T) {
1357 var buf bytes.Buffer
1358 printEmailPush(&buf, "jb-book", apiEmailPushReply{
1359 On: true, Path: "/mail/events", URL: "https://jb-book.beb.oncarlos.com/mail/events", KeyEnv: "CARLOS_MAIL_EVENTS_KEY",
1360 SuspendedAt: time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC),
1361 SuspendedWhy: "nothing was accepted for a day — the last attempt answered 404",
1362 }, false)
1363 out := buf.String()
1364 for _, want := range []string{"SUSPENDED", "answered 404", "given up on", "carlos email events push --app jb-book --path /mail/events"} {
1365 if !strings.Contains(out, want) {
1366 t.Errorf("missing %q:\n%s", want, out)
1367 }
1368 }
1369}
1370
1371// A member with more than one sender sees one line each, and a member with
1372// only the app sender sees exactly what they saw before.
1373func TestPrintEmailStatusRendersALinePerSender(t *testing.T) {
1374 var buf bytes.Buffer
1375 printEmailStatus(&buf, apiEmailReply{
1376 App: "harbourmail", DailyCap: 50, Active: true,
1377 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
1378 Counts: apiEmailCounts{Sends: 12},
1379 Senders: []apiEmailSender{
1380 {ID: "app", Scope: "app", Source: "domain", Active: true, DailyCap: 50, KeyID: "AKIA1",
1381 Counts: apiEmailCounts{Sends: 4}},
1382 {ID: "book.example.com", Scope: "instance", Source: "domain", Host: "book.example.com",
1383 Active: false, PauseKind: "cap", DailyCap: 1000, KeyID: "AKIA9",
1384 Counts: apiEmailCounts{Sends: 8, Bounces: 1}},
1385 },
1386 })
1387 out := buf.String()
1388 for _, want := range []string{"app", "book.example.com", "4/50", "8/1000"} {
1389 if !strings.Contains(out, want) {
1390 t.Errorf("status output is missing %q:\n%s", want, out)
1391 }
1392 }
1393 if !strings.Contains(strings.ToUpper(out), "PAUSED") {
1394 t.Errorf("a paused sender is not called out:\n%s", out)
1395 }
1396}
1397
1398// An older console answers with no senders array at all, and the output is
1399// the output it has always been.
1400func TestPrintEmailStatusWithNoSendersArrayIsUnchanged(t *testing.T) {
1401 var buf bytes.Buffer
1402 printEmailStatus(&buf, apiEmailReply{
1403 App: "harbourmail", DailyCap: 50, Active: true,
1404 Provisioned: &apiEmailProvisioned{KeyID: "AKIA1"},
1405 Counts: apiEmailCounts{Sends: 12},
1406 })
1407 if strings.Contains(buf.String(), "senders") {
1408 t.Errorf("a reply with no senders array printed a senders heading:\n%s", buf.String())
1409 }
1410}
1411