| 1 | package mail |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // The app sender is the shape every app has had since before senders |
| 15 | // existed, written under its own key. The four blocks move inside it |
| 16 | // unchanged, so a Sender decodes the same Declared/Grant/Provisioned/ |
| 17 | // Standalone an email.json object carries. |
| 18 | func TestSenderCarriesTheFourBlocksUnchanged(t *testing.T) { |
| 19 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 20 | sn := &Sender{ |
| 21 | Schema: Schema, ID: SenderAppID, Slug: "", Scope: ScopeApp, Source: SourceDomain, |
| 22 | Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"}, |
| 23 | Grant: &GrantBlock{Domains: []string{"x.com"}, DailyCap: 50, Source: "tier:free", At: now, By: "tier"}, |
| 24 | Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-hm", KeyID: "AKIA1", Active: true, At: now}, |
| 25 | Standalone: []*Standalone{{Name: "laptop", IAMUser: "carlos-mail-bag-hm+s+laptop", KeyID: "AKIA2", At: now, By: "owner@example.test"}}, |
| 26 | } |
| 27 | body, err := json.Marshal(sn) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | // Nothing this sender does not carry appears on the wire: per_instance, |
| 32 | // host, address, identity, tenant_arn and salt are all omitempty, so an |
| 33 | // app sender's object is the four blocks plus its own identity. |
| 34 | for _, absent := range []string{"per_instance", "host", "address", "identity", "tenant_arn", "salt"} { |
| 35 | if strings.Contains(string(body), absent) { |
| 36 | t.Errorf("an app sender's wire form carries %q: %s", absent, body) |
| 37 | } |
| 38 | } |
| 39 | var back Sender |
| 40 | if err := json.Unmarshal(body, &back); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if back.Declared.EnvPrefix != "HARBOURMAIL_SMTP" || back.Grant.DailyCap != 50 || |
| 44 | back.Provisioned.KeyID != "AKIA1" || len(back.Standalone) != 1 || back.Standalone[0].Name != "laptop" { |
| 45 | t.Fatalf("round trip lost a block: %+v", back) |
| 46 | } |
| 47 | if back.ID != SenderAppID || back.Scope != ScopeApp || back.Source != SourceDomain { |
| 48 | t.Fatalf("round trip lost the sender's identity: %+v", back) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestSenderKeysSitUnderTheAppsOwnPrefix(t *testing.T) { |
| 53 | if got := SendersPrefix("bag", "harbourmail"); got != "apps/bag/harbourmail/email/senders/" { |
| 54 | t.Fatalf("SendersPrefix = %q", got) |
| 55 | } |
| 56 | if got := SenderKey("bag", "harbourmail", SenderAppID); got != "apps/bag/harbourmail/email/senders/app.json" { |
| 57 | t.Fatalf("SenderKey = %q", got) |
| 58 | } |
| 59 | // An id is a bucket key segment, so a traversal or a separator is |
| 60 | // refused at the one gate every caller passes through. |
| 61 | for _, bad := range []string{"", "..", "a/b", "A", "x.json", strings.Repeat("a", 65)} { |
| 62 | if ValidSenderID(bad) { |
| 63 | t.Errorf("ValidSenderID(%q) = true", bad) |
| 64 | } |
| 65 | } |
| 66 | for _, good := range []string{"app", "book.example.com", "_carlos-1-abc123"} { |
| 67 | if !ValidSenderID(good) { |
| 68 | t.Errorf("ValidSenderID(%q) = false", good) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // The bounds, enumerated. SES caps a tenant name and a configuration-set |
| 74 | // name at 64 characters of [A-Za-z0-9_-]; IAM caps a user name at 64. An |
| 75 | // account sqid is at most 16 lowercase letters and an app name at most 32 |
| 76 | // of [a-z0-9-], so the app sender always fits and the instance sender only |
| 77 | // fits because the app component is truncated to 24 with the hash carrying |
| 78 | // uniqueness. |
| 79 | func TestSenderNamesEnumeratesTheBounds(t *testing.T) { |
| 80 | const longAcct = "abcdefghijklmnop" // 16, the longest legal account |
| 81 | const longApp = "a123456789012345678901234567890b" // 32, the longest legal app |
| 82 | if len(longAcct) != 16 || len(longApp) != 32 { |
| 83 | t.Fatalf("the fixture names are not at the bound: %d %d", len(longAcct), len(longApp)) |
| 84 | } |
| 85 | |
| 86 | // The app sender's names are today's names, byte for byte. |
| 87 | app, err := SenderNames(longAcct, longApp, SenderAppID, "") |
| 88 | if err != nil { |
| 89 | t.Fatalf("app sender at the bound: %v", err) |
| 90 | } |
| 91 | if app.Slug != "" { |
| 92 | t.Errorf("app sender slug = %q, want empty", app.Slug) |
| 93 | } |
| 94 | if app.IAMUser != UserName(longAcct, longApp) { |
| 95 | t.Errorf("IAMUser = %q, want %q — an existing name must never change", app.IAMUser, UserName(longAcct, longApp)) |
| 96 | } |
| 97 | if app.Tenant != TenantName(longAcct) || app.ConfigSet != ConfigSetName(longAcct) { |
| 98 | t.Errorf("tenant/set = %q/%q, want %q/%q", app.Tenant, app.ConfigSet, TenantName(longAcct), ConfigSetName(longAcct)) |
| 99 | } |
| 100 | if len(app.IAMUser) > 64 || len(app.Tenant) > 64 || len(app.ConfigSet) > 64 { |
| 101 | t.Errorf("app sender over a bound: %d/%d/%d", len(app.IAMUser), len(app.Tenant), len(app.ConfigSet)) |
| 102 | } |
| 103 | |
| 104 | // The instance sender at the same bound. 12 + 16 + 1 + 24 + 3 + 8 = 64. |
| 105 | inst, err := SenderNames(longAcct, longApp, "book.customer.example.com", "") |
| 106 | if err != nil { |
| 107 | t.Fatalf("instance sender at the bound: %v", err) |
| 108 | } |
| 109 | if len(inst.Slug) != 8 { |
| 110 | t.Fatalf("slug = %q, want 8 characters", inst.Slug) |
| 111 | } |
| 112 | if len(inst.IAMUser) != 64 { |
| 113 | t.Fatalf("IAMUser = %q (%d), want exactly 64 at the bound", inst.IAMUser, len(inst.IAMUser)) |
| 114 | } |
| 115 | if !strings.Contains(inst.IAMUser, "+i+"+inst.Slug) { |
| 116 | t.Errorf("IAMUser = %q, want it to end in +i+<slug>", inst.IAMUser) |
| 117 | } |
| 118 | if len(inst.Tenant) > 64 || !strings.HasSuffix(inst.Tenant, "-"+inst.Slug) { |
| 119 | t.Errorf("tenant = %q (%d)", inst.Tenant, len(inst.Tenant)) |
| 120 | } |
| 121 | for _, name := range []string{inst.IAMUser, inst.Tenant, inst.ConfigSet} { |
| 122 | for _, r := range name { |
| 123 | if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyz0123456789-_+.", r) { |
| 124 | t.Errorf("name %q holds %q, which SES or IAM does not accept", name, r) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // The slug is a function of (account, app, sender, salt) and nothing |
| 130 | // else, so a re-enable computes the same name. |
| 131 | again, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "") |
| 132 | if again.Slug != inst.Slug { |
| 133 | t.Fatalf("slug is not stable: %q then %q", inst.Slug, again.Slug) |
| 134 | } |
| 135 | salted, _ := SenderNames(longAcct, longApp, "book.customer.example.com", "0f0f") |
| 136 | if salted.Slug == inst.Slug { |
| 137 | t.Fatal("a salt did not move the slug, so a collision could never be resolved") |
| 138 | } |
| 139 | |
| 140 | // A standalone credential's name is unchanged where it fits and refused |
| 141 | // where it does not. carlos-mail- (12) + 16 + 1 + 32 + +s+ (3) + 31 |
| 142 | // reaches 95 against IAM's 64, which nothing checked before this. |
| 143 | if _, err := app.StandaloneUser(longAcct, longApp, "a123456789012345678901234567890"); !errors.Is(err, ErrNameTooLong) { |
| 144 | t.Fatalf("the longest standalone name on the longest app = %v, want ErrNameTooLong", err) |
| 145 | } |
| 146 | short, err := SenderNames("bag", "hm", SenderAppID, "") |
| 147 | if err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | got, err := short.StandaloneUser("bag", "hm", "laptop") |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | if got != StandaloneUserName("bag", "hm", "laptop") { |
| 155 | t.Fatalf("StandaloneUser = %q, want %q — RefreshPolicies recomputes this name and must get the live one", |
| 156 | got, StandaloneUserName("bag", "hm", "laptop")) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // The caller index is create-only with the domain claim's idempotence rule: |
| 161 | // the same (account, app, sender) is success, a different one is a |
| 162 | // collision. It is what makes a slugged IAM user name resolvable back to |
| 163 | // the sender that owns it, since nothing parses a hash. |
| 164 | func TestCallerIndexIsCreateOnlyAndIdempotentForItsOwnSender(t *testing.T) { |
| 165 | ctx := context.Background() |
| 166 | s := release.NewDirStore(t.TempDir()) |
| 167 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 168 | const user = "carlos-mail-bag-hm+i+abcd1234" |
| 169 | |
| 170 | if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | if err := PutCallerIndex(ctx, s, user, "bag", "hm", "book.example.com", now); err != nil { |
| 174 | t.Fatalf("a re-enable of the same sender was refused by the index it holds: %v", err) |
| 175 | } |
| 176 | if err := PutCallerIndex(ctx, s, user, "bag", "hm", "other.example.com", now); !errors.Is(err, ErrCallerClaimed) { |
| 177 | t.Fatalf("a different sender at the same name = %v, want ErrCallerClaimed", err) |
| 178 | } |
| 179 | got, err := GetCallerIndex(ctx, s, user) |
| 180 | if err != nil || got == nil || got.Account != "bag" || got.App != "hm" || got.Sender != "book.example.com" { |
| 181 | t.Fatalf("GetCallerIndex = %+v %v", got, err) |
| 182 | } |
| 183 | |
| 184 | // Deletion is owner-gated, the way releaseDomainClaim is: a stranger |
| 185 | // cannot remove an entry it does not hold. |
| 186 | if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "someone.else"); err != nil { |
| 187 | t.Fatalf("a mismatched delete should be a quiet no-op: %v", err) |
| 188 | } |
| 189 | if got, _ := GetCallerIndex(ctx, s, user); got == nil { |
| 190 | t.Fatal("a mismatched delete removed the entry") |
| 191 | } |
| 192 | if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if got, _ := GetCallerIndex(ctx, s, user); got != nil { |
| 196 | t.Fatalf("the entry survived its own delete: %+v", got) |
| 197 | } |
| 198 | // Deleting again is a quiet no-op, so a retried teardown is not an error. |
| 199 | if err := DeleteCallerIndex(ctx, s, user, "bag", "hm", "book.example.com"); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // A 40-bit slug needs collision detection, and the index is where it |
| 205 | // happens: the losing sender recomputes its slug with a salt that is stored |
| 206 | // on its record so the name stays stable across the next enable. |
| 207 | func TestResolveSenderNamesRetriesACollisionWithASalt(t *testing.T) { |
| 208 | ctx := context.Background() |
| 209 | s := release.NewDirStore(t.TempDir()) |
| 210 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 211 | |
| 212 | sn := &Sender{ID: "book.example.com", Scope: ScopeInstance, Source: SourceDomain, Host: "book.example.com"} |
| 213 | first, err := SenderNames("bag", "hm", sn.ID, "") |
| 214 | if err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | // Somebody else is already at the unsalted name. |
| 218 | if err := PutCallerIndex(ctx, s, first.IAMUser, "bag", "other", "book.example.com", now); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | |
| 222 | names, salted, err := ResolveSenderNames(ctx, s, "bag", "hm", sn, now) |
| 223 | if err != nil { |
| 224 | t.Fatalf("ResolveSenderNames: %v", err) |
| 225 | } |
| 226 | if !salted { |
| 227 | t.Fatal("the collision did not report that the record must be written back with its new salt") |
| 228 | } |
| 229 | if sn.Salt == "" { |
| 230 | t.Fatal("no salt was stored on the record, so the next enable would collide again") |
| 231 | } |
| 232 | if names.IAMUser == first.IAMUser { |
| 233 | t.Fatalf("the slug did not move: %q", names.IAMUser) |
| 234 | } |
| 235 | got, err := GetCallerIndex(ctx, s, names.IAMUser) |
| 236 | if err != nil || got == nil || got.App != "hm" { |
| 237 | t.Fatalf("the index at the new name = %+v %v", got, err) |
| 238 | } |
| 239 | // The app sender takes the same path and never needs a salt. |
| 240 | appSender := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain} |
| 241 | appNames, saltedApp, err := ResolveSenderNames(ctx, s, "bag", "hm", appSender, now) |
| 242 | if err != nil || saltedApp || appSender.Salt != "" { |
| 243 | t.Fatalf("the app sender was salted: %+v %v %v", appNames, saltedApp, err) |
| 244 | } |
| 245 | if appNames.IAMUser != UserName("bag", "hm") { |
| 246 | t.Fatalf("the app sender's name changed: %q", appNames.IAMUser) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // Parse first, index second. Every app enabled today short-circuits Enable |
| 251 | // before MintKey, so it has no index entry until it next rotates: a |
| 252 | // Get-only resolver would silently stop attributing their events the moment |
| 253 | // it shipped. |
| 254 | func TestSenderOfCallerParsesBeforeItReads(t *testing.T) { |
| 255 | ctx := context.Background() |
| 256 | s := release.NewDirStore(t.TempDir()) |
| 257 | now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC) |
| 258 | |
| 259 | // No index entry exists at all, and the delivered and standalone names |
| 260 | // still resolve, to the app sender. |
| 261 | for _, caller := range []string{ |
| 262 | UserName("bag", "harbourmail"), |
| 263 | StandaloneUserName("bag", "harbourmail", "laptop"), |
| 264 | "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-harbourmail", |
| 265 | } { |
| 266 | acct, app, sender, ok := SenderOfCaller(ctx, s, caller) |
| 267 | if !ok || acct != "bag" || app != "harbourmail" || sender != SenderAppID { |
| 268 | t.Errorf("SenderOfCaller(%q) = %q %q %q %v", caller, acct, app, sender, ok) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // A name nobody CARLOS minted stays unknown, and costs no read. |
| 273 | for _, caller := range []string{"", "someone-else", "carlos-mail-bag-harbourmail+s+"} { |
| 274 | if _, _, _, ok := SenderOfCaller(ctx, s, caller); ok { |
| 275 | t.Errorf("SenderOfCaller(%q) claimed to know it", caller) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | // A slugged name is unparseable by construction and comes from the index. |
| 280 | names, err := SenderNames("bag", "harbourmail", "book.example.com", "") |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if _, _, _, ok := SenderOfCaller(ctx, s, names.IAMUser); ok { |
| 285 | t.Fatal("a slugged name resolved before it was ever indexed") |
| 286 | } |
| 287 | if err := PutCallerIndex(ctx, s, names.IAMUser, "bag", "harbourmail", "book.example.com", now); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | acct, app, sender, ok := SenderOfCaller(ctx, s, names.IAMUser) |
| 291 | if !ok || acct != "bag" || app != "harbourmail" || sender != "book.example.com" { |
| 292 | t.Fatalf("SenderOfCaller(slug) = %q %q %q %v", acct, app, sender, ok) |
| 293 | } |
| 294 | } |
| 295 | |