carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
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.
18func 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
52func 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.
79func 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.
164func 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.
207func 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.
254func 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