package mail import ( "bytes" "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) func TestKeyIsUnderTheAppPrefix(t *testing.T) { if got, want := Key("bag", "harbourmail"), "apps/bag/harbourmail/email/senders/app.json"; got != want { t.Fatalf("Key = %q, want %q", got, want) } // The pre-senders object keeps its name, because Get still reads it and // the sweep still has to find it. if got, want := LegacyKey("bag", "harbourmail"), "apps/bag/harbourmail/email.json"; got != want { t.Fatalf("LegacyKey = %q, want %q", got, want) } } func TestEnvNamesMatchHarbourmailsContract(t *testing.T) { // The v1 criterion is zero harbourmail code changes, and harbourmail // reads exactly these five names (internal/app/signinflow.go). If this // test changes, the criterion is no longer met. host, port, user, pass, from := EnvNames("HARBOURMAIL_SMTP") for _, c := range []struct{ got, want string }{ {host, "HARBOURMAIL_SMTP_HOST"}, {port, "HARBOURMAIL_SMTP_PORT"}, {user, "HARBOURMAIL_SMTP_USER"}, {pass, "HARBOURMAIL_SMTP_PASS"}, {from, "HARBOURMAIL_SMTP_FROM"}, } { if c.got != c.want { t.Fatalf("EnvNames gave %q, want %q", c.got, c.want) } } } func TestValidEnvPrefixAcceptsHarbourmailAndRefusesJunk(t *testing.T) { for _, ok := range []string{"CARLOS_SMTP", "HARBOURMAIL_SMTP", "A"} { if !ValidEnvPrefix(ok) { t.Fatalf("ValidEnvPrefix(%q) = false, want true", ok) } } for _, bad := range []string{"", "lower", "1LEADING", "HAS-DASH", "THIS_PREFIX_IS_WAY_TOO_LONG_TO_BE_OK"} { if ValidEnvPrefix(bad) { t.Fatalf("ValidEnvPrefix(%q) = true, want false", bad) } } } func TestGetAbsentIsNotAnError(t *testing.T) { s := release.NewDirStore(t.TempDir()) rec, etag, err := Get(context.Background(), s, "bag", "harbourmail") if err != nil || rec != nil || etag != "" { t.Fatalf("Get on absent = (%v, %q, %v), want (nil, \"\", nil)", rec, etag, err) } } func TestPutThenGetRoundTrips(t *testing.T) { ctx, s := context.Background(), release.NewDirStore(t.TempDir()) now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) in := &Record{Declared: &Declared{ EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@harbourmail.bag.oncarlos.com", At: now, By: "owner@example.test", }} if _, err := Put(ctx, s, "bag", "harbourmail", in, ""); err != nil { t.Fatalf("Put: %v", err) } out, etag, err := Get(ctx, s, "bag", "harbourmail") if err != nil { t.Fatalf("Get: %v", err) } if etag == "" { t.Fatal("Get returned an empty etag for a present record") } if out.Schema != Schema || out.App != "harbourmail" { t.Fatalf("Put did not stamp schema/app: %+v", out) } if out.Declared.EnvPrefix != "HARBOURMAIL_SMTP" { t.Fatalf("declared did not round-trip: %+v", out.Declared) } } // The secret must never be in the record. This asserts on the marshalled // BYTES rather than the struct, because a future field added with the wrong // json tag would pass a struct-level check and still leak (spec §4). func TestRecordBytesCarryNoSecret(t *testing.T) { rec := &Record{Provisioned: &Provisioned{ IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIAEXAMPLE", Active: true, }} body, err := json.Marshal(rec) if err != nil { t.Fatalf("marshal: %v", err) } for _, forbidden := range []string{"secret", "password", "pass"} { if containsFold(string(body), forbidden) { t.Fatalf("record JSON mentions %q: %s", forbidden, body) } } } func containsFold(haystack, needle string) bool { return len(needle) > 0 && len(haystack) >= len(needle) && strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) } // The migration (design §5). Get prefers senders/app.json and falls back to // email.json; when it served the fallback it returns an EMPTY etag. Every // writer passes back the etag it was given, so the next Put is a create by // construction, and a lost race is ErrCASConflict exactly as it is today. func TestGetFallsBackToEmailJSONWithAnEmptyETag(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) legacy := &Record{Schema: Schema, App: "harbourmail", Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"}, Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now}, } body, err := json.MarshalIndent(legacy, "", " ") if err != nil { t.Fatal(err) } if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), append(body, '\n'), ""); err != nil { t.Fatal(err) } rec, etag, err := Get(ctx, s, "bag", "harbourmail") if err != nil || rec == nil { t.Fatalf("Get = %+v %v", rec, err) } if etag != "" { t.Fatalf("etag = %q, want empty so the next Put is a create", etag) } if rec.Provisioned.KeyID != "AKIA1" { t.Fatalf("the fallback lost the record: %+v", rec) } // Put always writes the sender key and never the legacy one. if _, err := Put(ctx, s, "bag", "harbourmail", rec, etag); err != nil { t.Fatalf("Put after the fallback: %v", err) } if _, _, err := s.Get(ctx, Key("bag", "harbourmail")); err != nil { t.Fatalf("senders/app.json was not written: %v", err) } // email.json is left in place, unwritten, for one release. raw, _, err := s.Get(ctx, LegacyKey("bag", "harbourmail")) if err != nil || !bytes.Contains(raw, []byte("AKIA1")) { t.Fatalf("email.json was disturbed: %q %v", raw, err) } // Once the sender object exists it is preferred, with a real etag. rec2, etag2, err := Get(ctx, s, "bag", "harbourmail") if err != nil || rec2 == nil || etag2 == "" { t.Fatalf("Get after the migration = %+v %q %v", rec2, etag2, err) } if rec2.Provisioned.KeyID != "AKIA1" || rec2.Declared.From != "noreply@x.com" { t.Fatalf("the migrated record lost a block: %+v", rec2) } // A second writer still holding the fallback's empty etag loses the race // rather than clobbering the migrated object. if _, err := Put(ctx, s, "bag", "harbourmail", rec, ""); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("a create over an existing sender = %v, want ErrCASConflict", err) } } // An app with neither object is "no email", a state and not an error. func TestGetWithNothingWrittenIsNotAnError(t *testing.T) { rec, etag, err := Get(context.Background(), release.NewDirStore(t.TempDir()), "bag", "harbourmail") if rec != nil || etag != "" || err != nil { t.Fatalf("Get = %+v %q %v", rec, etag, err) } } // ListSenders answers for an app that has never been migrated, because the // status reply reads it on every call and an unmigrated app must not look // like an app with no senders at all. func TestListSendersAnswersFromTheLegacyRecord(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) body, _ := json.Marshal(&Record{Schema: Schema, App: "harbourmail", Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}}) if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), body, ""); err != nil { t.Fatal(err) } got, err := ListSenders(ctx, s, "bag", "harbourmail") if err != nil || len(got) != 1 { t.Fatalf("ListSenders = %+v %v, want one sender", got, err) } if got[0].ID != SenderAppID || got[0].Scope != ScopeApp || got[0].Source != SourceDomain { t.Fatalf("the legacy record did not read back as the app sender: %+v", got[0]) } } // errOnGetStore wraps a release.Store and forces Get on one specific key to // fail with a non-NotExist error, standing in for a transient store error // as distinct from that key being genuinely absent. type errOnGetStore struct { release.Store failKey string err error } func (s *errOnGetStore) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.failKey { return nil, "", s.err } return s.Store.Get(ctx, key) } // ListSenders must not read the legacy record when the app sender's OWN key // errored rather than being absent: a store hiccup reading senders/app.json // is not proof the app was never migrated, and falling back to email.json // would hand the status reply and the resume loop the STALE pre-migration // Active/PauseKind instead of an error they can retry against. func TestListSendersDoesNotFallBackToLegacyOnAnErroredRead(t *testing.T) { ctx := context.Background() dir := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) // The stale, pre-migration record: paused. legacy, err := json.Marshal(&Record{Schema: Schema, App: "harbourmail", Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}, Provisioned: &Provisioned{Active: false, PauseKind: PauseKindCap, At: now}}) if err != nil { t.Fatal(err) } if _, err := dir.Put(ctx, LegacyKey("bag", "harbourmail"), legacy, ""); err != nil { t.Fatal(err) } // The migrated, current record: active. sn := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain, Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}, Provisioned: &Provisioned{Active: true, At: now}} if _, err := PutSender(ctx, dir, "bag", "harbourmail", sn, ""); err != nil { t.Fatal(err) } failing := &errOnGetStore{ Store: dir, failKey: SenderKey("bag", "harbourmail", SenderAppID), err: errors.New("mail_test: injected transient store error"), } got, err := ListSenders(ctx, failing, "bag", "harbourmail") if err == nil { t.Fatalf("ListSenders = %+v, want an error rather than the stale legacy record", got) } } // The Record view's read-modify-write must not lose the fields Record has // no name for. Tasks 5 and 7 start populating PerInstance, Identity, // Address, TenantARN and Salt directly on the Sender; if sn.record() or // rec.sender() forgot one, an ordinary pause would silently clear it. // SetPaused is the realistic shape: it reads through Record, changes only // the Provisioned block, and writes back through Put. func TestGetPutPreservesFieldsRecordHasNoNameFor(t *testing.T) { ctx := context.Background() s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1") now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) sn := &Sender{ ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain, Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now}, PerInstance: true, Identity: "arn:aws:ses:eu-west-1:1:identity/harbourmail.bag.oncarlos.com", Address: "noreply@harbourmail.bag.oncarlos.com", TenantARN: "arn:aws:ses:eu-west-1:1:tenant/carlos-bag", Salt: "s3cr3tsalt", } if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, ""); err != nil { t.Fatalf("PutSender: %v", err) } if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindCap, now); err != nil { t.Fatalf("SetPaused: %v", err) } got, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID) if err != nil || got == nil { t.Fatalf("GetSender = %+v %v", got, err) } if got.Provisioned == nil || got.Provisioned.Active || got.Provisioned.PauseKind != PauseKindCap { t.Fatalf("SetPaused's own change did not take: %+v", got.Provisioned) } if !got.PerInstance { t.Fatalf("the pause's read-modify-write cleared PerInstance: %+v", got) } if got.Identity != sn.Identity || got.Address != sn.Address || got.TenantARN != sn.TenantARN || got.Salt != sn.Salt { t.Fatalf("the pause's read-modify-write lost a field: %+v", got) } }