package mail import ( "context" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/release" ) // The app sender is the shape every app has had since before senders // existed, written under its own key. The four blocks move inside it // unchanged, so a Sender decodes the same Declared/Grant/Provisioned/ // Standalone an email.json object carries. func TestSenderCarriesTheFourBlocksUnchanged(t *testing.T) { now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) sn := &Sender{ Schema: Schema, ID: SenderAppID, Slug: "", Scope: ScopeApp, Source: SourceDomain, Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"}, Grant: &GrantBlock{Domains: []string{"x.com"}, DailyCap: 50, Source: "tier:free", At: now, By: "tier"}, Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-hm", KeyID: "AKIA1", Active: true, At: now}, Standalone: []*Standalone{{Name: "laptop", IAMUser: "carlos-mail-bag-hm+s+laptop", KeyID: "AKIA2", At: now, By: "owner@example.test"}}, } body, err := json.Marshal(sn) if err != nil { t.Fatal(err) } // Nothing this sender does not carry appears on the wire: per_instance, // host, address, identity, tenant_arn and salt are all omitempty, so an // app sender's object is the four blocks plus its own identity. for _, absent := range []string{"per_instance", "host", "address", "identity", "tenant_arn", "salt"} { if strings.Contains(string(body), absent) { t.Errorf("an app sender's wire form carries %q: %s", absent, body) } } var back Sender if err := json.Unmarshal(body, &back); err != nil { t.Fatal(err) } if back.Declared.EnvPrefix != "HARBOURMAIL_SMTP" || back.Grant.DailyCap != 50 || back.Provisioned.KeyID != "AKIA1" || len(back.Standalone) != 1 || back.Standalone[0].Name != "laptop" { t.Fatalf("round trip lost a block: %+v", back) } if back.ID != SenderAppID || back.Scope != ScopeApp || back.Source != SourceDomain { t.Fatalf("round trip lost the sender's identity: %+v", back) } } func TestSenderKeysSitUnderTheAppsOwnPrefix(t *testing.T) { if got := SendersPrefix("bag", "harbourmail"); got != "apps/bag/harbourmail/email/senders/" { t.Fatalf("SendersPrefix = %q", got) } if got := SenderKey("bag", "harbourmail", SenderAppID); got != "apps/bag/harbourmail/email/senders/app.json" { t.Fatalf("SenderKey = %q", got) } // An id is a bucket key segment, so a traversal or a separator is // refused at the one gate every caller passes through. for _, bad := range []string{"", "..", "a/b", "A", "x.json", strings.Repeat("a", 65)} { if ValidSenderID(bad) { t.Errorf("ValidSenderID(%q) = true", bad) } } for _, good := range []string{"app", "book.example.com", "_carlos-1-abc123"} { if !ValidSenderID(good) { t.Errorf("ValidSenderID(%q) = false", good) } } } // The bounds, enumerated. SES caps a tenant name and a configuration-set // name at 64 characters of [A-Za-z0-9_-]; IAM caps a user name at 64. An // account sqid is at most 16 lowercase letters and an app name at most 32 // of [a-z0-9-], so the app sender always fits and the instance sender only // fits because the app component is truncated to 24 with the hash carrying // uniqueness. func TestSenderNamesEnumeratesTheBounds(t *testing.T) { const longAcct = "abcdefghijklmnop" // 16, the longest legal account const longApp = "a123456789012345678901234567890b" // 32, the longest legal app if len(longAcct) != 16 || len(longApp) != 32 { t.Fatalf("the fixture names are not at the bound: %d %d", len(longAcct), len(longApp)) } // The app sender's names are today's names, byte for byte. app, err := SenderNames(longAcct, longApp, SenderAppID, "") if err != nil { t.Fatalf("app sender at the bound: %v", err) } if app.Slug != "" { t.Errorf("app sender slug = %q, want empty", app.Slug) } if app.IAMUser != UserName(longAcct, longApp) { t.Errorf("IAMUser = %q, want %q — an existing name must never change", app.IAMUser, UserName(longAcct, longApp)) } if app.Tenant != TenantName(longAcct) || app.ConfigSet != ConfigSetName(longAcct) { t.Errorf("tenant/set = %q/%q, want %q/%q", app.Tenant, app.ConfigSet, TenantName(longAcct), ConfigSetName(longAcct)) } if len(app.IAMUser) > 64 || len(app.Tenant) > 64 || len(app.ConfigSet) > 64 { t.Errorf("app sender over a bound: %d/%d/%d", len(app.IAMUser), len(app.Tenant), len(app.ConfigSet)) } // The instance sender at the same bound. 12 + 16 + 1 + 24 + 3 + 8 = 64. inst, err := SenderNames(longAcct, longApp, "book.customer.example.com", "") if err != nil { t.Fatalf("instance sender at the bound: %v", err) } if len(inst.Slug) != 8 { t.Fatalf("slug = %q, want 8 characters", inst.Slug) } if len(inst.IAMUser) != 64 { t.Fatalf("IAMUser = %q (%d), want exactly 64 at the bound", inst.IAMUser, len(inst.IAMUser)) } if !strings.Contains(inst.IAMUser, "+i+"+inst.Slug) { t.Errorf("IAMUser = %q, want it to end in +i+", inst.IAMUser) } if len(inst.Tenant) > 64 || !strings.HasSuffix(inst.Tenant, "-"+inst.Slug) { t.Errorf("tenant = %q (%d)", inst.Tenant, len(inst.Tenant)) } for _, name := range []string{inst.IAMUser, inst.Tenant, inst.ConfigSet} { for _, r := range name { if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-_+.", r) { t.Errorf("name %q holds %q, which SES or IAM does not accept", name, r) } } } // The slug is a function of (account, app, sender, salt) and nothing // else, so a re-enable computes the same name. again, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "") if again.Slug != inst.Slug { t.Fatalf("slug is not stable: %q then %q", inst.Slug, again.Slug) } salted, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "0f0f") if salted.Slug == inst.Slug { t.Fatal("a salt did not move the slug, so a collision could never be resolved") } // A standalone credential's name is unchanged where it fits and refused // where it does not. carlos-mail- (12) + 16 + 1 + 32 + +s+ (3) + 31 // reaches 95 against IAM's 64, which nothing checked before this. if _, err := app.StandaloneUser(longAcct, longApp, "a123456789012345678901234567890"); !errors.Is(err, ErrNameTooLong) { t.Fatalf("the longest standalone name on the longest app = %v, want ErrNameTooLong", err) } short, err := SenderNames("bag", "hm", SenderAppID, "") if err != nil { t.Fatal(err) } got, err := short.StandaloneUser("bag", "hm", "laptop") if err != nil { t.Fatal(err) } if got != StandaloneUserName("bag", "hm", "laptop") { t.Fatalf("StandaloneUser = %q, want %q — RefreshPolicies recomputes this name and must get the live one", got, StandaloneUserName("bag", "hm", "laptop")) } } // The caller index is create-only with the domain claim's idempotence rule: // the same (account, app, sender) is success, a different one is a // collision. It is what makes a slugged IAM user name resolvable back to // the sender that owns it, since nothing parses a hash. func TestCallerIndexIsCreateOnlyAndIdempotentForItsOwnSender(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) const user = "carlos-mail-bag-hm+i+abcd1234" if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { t.Fatal(err) } if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { t.Fatalf("a re-enable of the same sender was refused by the index it holds: %v", err) } if err := PutCallerIndex(ctx, s, user, "bag", "hm", "other.example.com", now); !errors.Is(err, ErrCallerClaimed) { t.Fatalf("a different sender at the same name = %v, want ErrCallerClaimed", err) } got, err := GetCallerIndex(ctx, s, user) if err != nil || got == nil || got.Account != "bag" || got.App != "hm" || got.Sender != "book.example.com" { t.Fatalf("GetCallerIndex = %+v %v", got, err) } // Deletion is owner-gated, the way releaseDomainClaim is: a stranger // cannot remove an entry it does not hold. if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "someone.else"); err != nil { t.Fatalf("a mismatched delete should be a quiet no-op: %v", err) } if got, _ := GetCallerIndex(ctx, s, user); got == nil { t.Fatal("a mismatched delete removed the entry") } if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { t.Fatal(err) } if got, _ := GetCallerIndex(ctx, s, user); got != nil { t.Fatalf("the entry survived its own delete: %+v", got) } // Deleting again is a quiet no-op, so a retried teardown is not an error. if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { t.Fatal(err) } } // A 40-bit slug needs collision detection, and the index is where it // happens: the losing sender recomputes its slug with a salt that is stored // on its record so the name stays stable across the next enable. func TestResolveSenderNamesRetriesACollisionWithASalt(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) sn := &Sender{ID: "book.example.com", Scope: ScopeInstance, Source: SourceDomain, Host: "book.example.com"} first, err := SenderNames("bag", "hm", sn.ID, "") if err != nil { t.Fatal(err) } // Somebody else is already at the unsalted name. if err := PutCallerIndex(ctx, s, first.IAMUser, "bag", "other", "book.example.com", now); err != nil { t.Fatal(err) } names, salted, err := ResolveSenderNames(ctx, s, "bag", "hm", sn, now) if err != nil { t.Fatalf("ResolveSenderNames: %v", err) } if !salted { t.Fatal("the collision did not report that the record must be written back with its new salt") } if sn.Salt == "" { t.Fatal("no salt was stored on the record, so the next enable would collide again") } if names.IAMUser == first.IAMUser { t.Fatalf("the slug did not move: %q", names.IAMUser) } got, err := GetCallerIndex(ctx, s, names.IAMUser) if err != nil || got == nil || got.App != "hm" { t.Fatalf("the index at the new name = %+v %v", got, err) } // The app sender takes the same path and never needs a salt. appSender := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain} appNames, saltedApp, err := ResolveSenderNames(ctx, s, "bag", "hm", appSender, now) if err != nil || saltedApp || appSender.Salt != "" { t.Fatalf("the app sender was salted: %+v %v %v", appNames, saltedApp, err) } if appNames.IAMUser != UserName("bag", "hm") { t.Fatalf("the app sender's name changed: %q", appNames.IAMUser) } } // Parse first, index second. Every app enabled today short-circuits Enable // before MintKey, so it has no index entry until it next rotates: a // Get-only resolver would silently stop attributing their events the moment // it shipped. func TestSenderOfCallerParsesBeforeItReads(t *testing.T) { ctx := context.Background() s := release.NewDirStore(t.TempDir()) now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) // No index entry exists at all, and the delivered and standalone names // still resolve, to the app sender. for _, caller := range []string{ UserName("bag", "harbourmail"), StandaloneUserName("bag", "harbourmail", "laptop"), "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-harbourmail", } { acct, app, sender, ok := SenderOfCaller(ctx, s, caller) if !ok || acct != "bag" || app != "harbourmail" || sender != SenderAppID { t.Errorf("SenderOfCaller(%q) = %q %q %q %v", caller, acct, app, sender, ok) } } // A name nobody CARLOS minted stays unknown, and costs no read. for _, caller := range []string{"", "someone-else", "carlos-mail-bag-harbourmail+s+"} { if _, _, _, ok := SenderOfCaller(ctx, s, caller); ok { t.Errorf("SenderOfCaller(%q) claimed to know it", caller) } } // A slugged name is unparseable by construction and comes from the index. names, err := SenderNames("bag", "harbourmail", "book.example.com", "") if err != nil { t.Fatal(err) } if _, _, _, ok := SenderOfCaller(ctx, s, names.IAMUser); ok { t.Fatal("a slugged name resolved before it was ever indexed") } if err := PutCallerIndex(ctx, s, names.IAMUser, "bag", "harbourmail", "book.example.com", now); err != nil { t.Fatal(err) } acct, app, sender, ok := SenderOfCaller(ctx, s, names.IAMUser) if !ok || acct != "bag" || app != "harbourmail" || sender != "book.example.com" { t.Fatalf("SenderOfCaller(slug) = %q %q %q %v", acct, app, sender, ok) } }