| index 9c1985f31..0d668e52a 100644 |
| --- a/internal/console/appsettings_test.go |
| +++ b/internal/console/appsettings_test.go |
| @@ -1138,3 +1138,304 @@ func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) { |
| t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains) |
| } |
| } |
| + |
| +// ---- Settings tab: the Email card (issue #240) ------------------------------ |
| +// |
| +// mailCard is object storage's sibling (storeCard, above) — same declare -> |
| +// grant -> provision -> rotate shape, same error discipline: a read failure |
| +// returns nil so the page says nothing about email at all rather than |
| +// guessing, and an absent record returns a zero card, the shape the |
| +// empty-state line renders from. Available is the one field that does NOT |
| +// come from the record at all: it is s.cfg.MailProvisioner != nil, read |
| +// before the record is, because it decides which of the two empty-state |
| +// sentences a zero card gets — and a deployment with no provisioner must |
| +// never be told to run a command that mailProvisionerGate would 404. |
| + |
| +// putMailRecord seeds apps/<acct>/<app>/email.json directly — the state the |
| +// card has to report on and the walk functions (Declare/ApplyGrant/Enable/ |
| +// SetPaused) would normally reach through their own doors. |
| +func putMailRecord(t *testing.T, fx *consoleFixture, app string, rec *mail.Record) { |
| + t.Helper() |
| + if _, err := mail.Put(context.Background(), fx.rel, fx.sqid, app, rec, ""); err != nil { |
| + t.Fatalf("seed mail record for %s: %v", app, err) |
| + } |
| +} |
| + |
| +// mailCardAccount is the fixture account as mailCard's Account parameter — |
| +// only Sqid is read, so a bare literal is enough; AccountBySqid's roundtrip |
| +// through the console database would prove nothing extra. |
| +func mailCardAccount(fx *consoleFixture) *Account { return &Account{Sqid: fx.sqid} } |
| + |
| +func TestMailCardStates(t *testing.T) { |
| + now := time.Now() |
| + cases := []struct { |
| + name string |
| + app string |
| + rec *mail.Record // nil = write nothing |
| + provisionerNil bool |
| + check func(t *testing.T, card *MailCard) |
| + }{ |
| + { |
| + name: "absent record", |
| + app: "mail-absent", |
| + check: func(t *testing.T, card *MailCard) { |
| + if card == nil { |
| + t.Fatal("absent record with no read error returned nil, want a zero card") |
| + } |
| + if !card.Available { |
| + t.Fatal("Available = false with a MailProvisioner configured") |
| + } |
| + if card.Declared || card.Granted || card.Provisioned || len(card.Standalone) != 0 { |
| + t.Fatalf("absent record produced a non-zero card: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "declared only", |
| + app: "mail-declared", |
| + rec: &mail.Record{Declared: &mail.Declared{ |
| + EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now, By: "paul@keymail.dev", |
| + }}, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Declared || card.EnvPrefix != "CARLOS_SMTP" || card.From != "hi@example.com" { |
| + t.Fatalf("declared-only card = %+v", card) |
| + } |
| + if card.Granted || card.Provisioned { |
| + t.Fatalf("declared-only card claims more than declared: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "granted", |
| + app: "mail-granted", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, Source: "tier:free", At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Granted || card.DailyCap != 500 { |
| + t.Fatalf("granted card = %+v", card) |
| + } |
| + if len(card.Domains) != 1 || card.Domains[0] != "example.com" { |
| + t.Fatalf("granted card domains = %+v, want [example.com]", card.Domains) |
| + } |
| + if card.Provisioned { |
| + t.Fatalf("granted-only card claims provisioned: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "provisioned", |
| + app: "mail-provisioned", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Grant: &mail.GrantBlock{Domains: []string{"example.com"}, DailyCap: 500, At: now}, |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now.Add(-2 * time.Hour)}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Provisioned || card.KeyID != "AKIA1" { |
| + t.Fatalf("provisioned card = %+v", card) |
| + } |
| + if card.KeyAge == "" { |
| + t.Fatal("provisioned card has no KeyAge") |
| + } |
| + if card.Paused || card.PendingFinish { |
| + t.Fatalf("a healthy provisioned card reads paused or pending-finish: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "mid-rotation", |
| + app: "mail-rotating", |
| + rec: &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now}, |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA2", PrevKeyID: "AKIA1", Active: true, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.PendingFinish { |
| + t.Fatalf("a record with a PrevKeyID did not set PendingFinish: %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-cap", |
| + app: "mail-paused-cap", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA3", Active: false, PauseKind: mail.PauseKindCap, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != mail.PauseKindCap { |
| + t.Fatalf("paused-cap card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-rate", |
| + app: "mail-paused-rate", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA4", Active: false, PauseKind: mail.PauseKindRate, At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != mail.PauseKindRate { |
| + t.Fatalf("paused-rate card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "paused-manual", |
| + app: "mail-paused-manual", |
| + rec: &mail.Record{ |
| + Provisioned: &mail.Provisioned{KeyID: "AKIA5", Active: false, PauseKind: "", At: now}, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if !card.Paused || card.PauseKind != "" { |
| + t.Fatalf("paused-manual card = %+v", card) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "with standalone credentials", |
| + app: "mail-standalone", |
| + rec: &mail.Record{ |
| + Standalone: []*mail.Standalone{ |
| + {Name: "mailgun-fallback", KeyID: "AKIASTAND", At: now.Add(-24 * time.Hour), By: "paul@keymail.dev"}, |
| + }, |
| + }, |
| + check: func(t *testing.T, card *MailCard) { |
| + if len(card.Standalone) != 1 { |
| + t.Fatalf("standalone card = %+v", card) |
| + } |
| + row := card.Standalone[0] |
| + if row.Name != "mailgun-fallback" || row.KeyID != "AKIASTAND" || row.Age == "" { |
| + t.Fatalf("standalone row = %+v", row) |
| + } |
| + }, |
| + }, |
| + { |
| + name: "provisioner nil", |
| + app: "mail-no-provisioner", |
| + provisionerNil: true, |
| + check: func(t *testing.T, card *MailCard) { |
| + if card == nil { |
| + t.Fatal("provisioner-nil, absent record returned nil, want a zero card") |
| + } |
| + if card.Available { |
| + t.Fatal("Available = true with no MailProvisioner configured") |
| + } |
| + if card.Declared || card.Granted || card.Provisioned { |
| + t.Fatalf("provisioner-nil card is not otherwise zero: %+v", card) |
| + } |
| + }, |
| + }, |
| + } |
| + |
| + for _, tc := range cases { |
| + tc := tc |
| + t.Run(tc.name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if !tc.provisionerNil { |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + } |
| + if tc.rec != nil { |
| + putMailRecord(t, fx, tc.app, tc.rec) |
| + } |
| + card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), tc.app) |
| + tc.check(t, card) |
| + }) |
| + } |
| +} |
| + |
| +// unreadableMailStore breaks exactly the email record's read — the state a |
| +// bucket hiccup (or a permissions gap on this one object) leaves behind. |
| +type unreadableMailStore struct{ release.Store } |
| + |
| +func (u unreadableMailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasSuffix(key, "/email.json") { |
| + return nil, "", errors.New("AccessDenied: s3://carlos-releases/apps/.../email.json request id XYZ") |
| + } |
| + return u.Store.Get(ctx, key) |
| +} |
| + |
| +// TestMailCardReadErrorReturnsNilNotAZeroCard is storeCard's own discipline, |
| +// carried over: "could not read the record" and "there is genuinely no |
| +// email" must never render the same way. A zero card here would tell a |
| +// member "no email for this app yet, get it with carlos email enable" on a |
| +// page that could not actually confirm that — mailCard must instead return |
| +// nil, which the template renders as nothing at all about email. |
| +func TestMailCardReadErrorReturnsNilNotAZeroCard(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + fx.srv.cfg.Releases = unreadableMailStore{fx.rel} |
| + |
| + card := fx.srv.mailCard(context.Background(), mailCardAccount(fx), "kass") |
| + if card != nil { |
| + t.Fatalf("a read failure returned %+v, want nil", card) |
| + } |
| +} |
| + |
| +// TestSettingsPageShowsTheEmailCard is the render half: a card field the |
| +// template never reads does not exist for the member looking at the page, |
| +// and the paused state above all else must reach the HTML — a paused app is |
| +// sending nothing, and this must be impossible to miss. |
| +func TestSettingsPageShowsTheEmailCard(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = mail.NewMemProvisioner("1") |
| + putMailRecord(t, fx, "kass", &mail.Record{ |
| + Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@kass.example.com", At: time.Now()}, |
| + Grant: &mail.GrantBlock{Domains: []string{"kass.example.com"}, DailyCap: 500, At: time.Now()}, |
| + Provisioned: &mail.Provisioned{ |
| + KeyID: "AKIAPAUSED", Active: false, PauseKind: mail.PauseKindRate, At: time.Now(), |
| + }, |
| + Standalone: []*mail.Standalone{{Name: "legacy-notifier", KeyID: "AKIASTAND1", At: time.Now()}}, |
| + }) |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + for _, want := range []string{ |
| + "CARLOS_SMTP", // the env prefix |
| + "hi@kass.example.com", // the From address |
| + "kass.example.com", // the granted sending domain |
| + "AKIAPAUSED", // the SMTP username |
| + "AKIASTAND1", // the standalone credential's key id |
| + "legacy-notifier", // the standalone credential's name |
| + "Sending is paused", // impossible to miss |
| + "does not clear itself", // the rate-pause distinction from a cap pause |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("the email card is missing %q:\n%s", want, body) |
| + } |
| + } |
| + |
| + // An app nobody has declared email for, on a console WITH a provisioner, |
| + // gets the command that can actually run. |
| + freshApp(t, fx, "sprout") |
| + _, _, body = get(t, c, fx.u("/apps/sprout/settings")) |
| + if !strings.Contains(body, "carlos email enable -app sprout") { |
| + t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body) |
| + } |
| +} |
| + |
| +// TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner is the |
| +// judgement call the brief calls out: a deployment with no MailProvisioner |
| +// must never suggest a command that mailProvisionerGate would 404 — |
| +// suggesting `carlos email enable` there would tell the member to run |
| +// something that cannot work. |
| +func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailProvisioner = nil |
| + c := signInPaul(t, fx) |
| + |
| + status, _, body := get(t, c, fx.u("/apps/kass/settings")) |
| + if status != http.StatusOK { |
| + t.Fatalf("settings page = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "not available on this deployment") { |
| + t.Fatalf("a provisioner-less console does not say email is unavailable:\n%s", body) |
| + } |
| + if strings.Contains(body, "carlos email enable") { |
| + t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body) |
| + } |
| +} |