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 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/release"
13)
14
15func TestKeyIsUnderTheAppPrefix(t *testing.T) {
16 if got, want := Key("bag", "harbourmail"), "apps/bag/harbourmail/email/senders/app.json"; got != want {
17 t.Fatalf("Key = %q, want %q", got, want)
18 }
19 // The pre-senders object keeps its name, because Get still reads it and
20 // the sweep still has to find it.
21 if got, want := LegacyKey("bag", "harbourmail"), "apps/bag/harbourmail/email.json"; got != want {
22 t.Fatalf("LegacyKey = %q, want %q", got, want)
23 }
24}
25
26func TestEnvNamesMatchHarbourmailsContract(t *testing.T) {
27 // The v1 criterion is zero harbourmail code changes, and harbourmail
28 // reads exactly these five names (internal/app/signinflow.go). If this
29 // test changes, the criterion is no longer met.
30 host, port, user, pass, from := EnvNames("HARBOURMAIL_SMTP")
31 for _, c := range []struct{ got, want string }{
32 {host, "HARBOURMAIL_SMTP_HOST"}, {port, "HARBOURMAIL_SMTP_PORT"},
33 {user, "HARBOURMAIL_SMTP_USER"}, {pass, "HARBOURMAIL_SMTP_PASS"},
34 {from, "HARBOURMAIL_SMTP_FROM"},
35 } {
36 if c.got != c.want {
37 t.Fatalf("EnvNames gave %q, want %q", c.got, c.want)
38 }
39 }
40}
41
42func TestValidEnvPrefixAcceptsHarbourmailAndRefusesJunk(t *testing.T) {
43 for _, ok := range []string{"CARLOS_SMTP", "HARBOURMAIL_SMTP", "A"} {
44 if !ValidEnvPrefix(ok) {
45 t.Fatalf("ValidEnvPrefix(%q) = false, want true", ok)
46 }
47 }
48 for _, bad := range []string{"", "lower", "1LEADING", "HAS-DASH", "THIS_PREFIX_IS_WAY_TOO_LONG_TO_BE_OK"} {
49 if ValidEnvPrefix(bad) {
50 t.Fatalf("ValidEnvPrefix(%q) = true, want false", bad)
51 }
52 }
53}
54
55func TestGetAbsentIsNotAnError(t *testing.T) {
56 s := release.NewDirStore(t.TempDir())
57 rec, etag, err := Get(context.Background(), s, "bag", "harbourmail")
58 if err != nil || rec != nil || etag != "" {
59 t.Fatalf("Get on absent = (%v, %q, %v), want (nil, \"\", nil)", rec, etag, err)
60 }
61}
62
63func TestPutThenGetRoundTrips(t *testing.T) {
64 ctx, s := context.Background(), release.NewDirStore(t.TempDir())
65 now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
66 in := &Record{Declared: &Declared{
67 EnvPrefix: "HARBOURMAIL_SMTP",
68 From: "noreply@harbourmail.bag.oncarlos.com",
69 At: now, By: "owner@example.test",
70 }}
71 if _, err := Put(ctx, s, "bag", "harbourmail", in, ""); err != nil {
72 t.Fatalf("Put: %v", err)
73 }
74 out, etag, err := Get(ctx, s, "bag", "harbourmail")
75 if err != nil {
76 t.Fatalf("Get: %v", err)
77 }
78 if etag == "" {
79 t.Fatal("Get returned an empty etag for a present record")
80 }
81 if out.Schema != Schema || out.App != "harbourmail" {
82 t.Fatalf("Put did not stamp schema/app: %+v", out)
83 }
84 if out.Declared.EnvPrefix != "HARBOURMAIL_SMTP" {
85 t.Fatalf("declared did not round-trip: %+v", out.Declared)
86 }
87}
88
89// The secret must never be in the record. This asserts on the marshalled
90// BYTES rather than the struct, because a future field added with the wrong
91// json tag would pass a struct-level check and still leak (spec §4).
92func TestRecordBytesCarryNoSecret(t *testing.T) {
93 rec := &Record{Provisioned: &Provisioned{
94 IAMUser: "carlos-mail-bag-harbourmail",
95 KeyID: "AKIAEXAMPLE",
96 Active: true,
97 }}
98 body, err := json.Marshal(rec)
99 if err != nil {
100 t.Fatalf("marshal: %v", err)
101 }
102 for _, forbidden := range []string{"secret", "password", "pass"} {
103 if containsFold(string(body), forbidden) {
104 t.Fatalf("record JSON mentions %q: %s", forbidden, body)
105 }
106 }
107}
108
109func containsFold(haystack, needle string) bool {
110 return len(needle) > 0 && len(haystack) >= len(needle) &&
111 strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
112}
113
114// The migration (design §5). Get prefers senders/app.json and falls back to
115// email.json; when it served the fallback it returns an EMPTY etag. Every
116// writer passes back the etag it was given, so the next Put is a create by
117// construction, and a lost race is ErrCASConflict exactly as it is today.
118func TestGetFallsBackToEmailJSONWithAnEmptyETag(t *testing.T) {
119 ctx := context.Background()
120 s := release.NewDirStore(t.TempDir())
121 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
122
123 legacy := &Record{Schema: Schema, App: "harbourmail",
124 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "owner@example.test"},
125 Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now},
126 }
127 body, err := json.MarshalIndent(legacy, "", " ")
128 if err != nil {
129 t.Fatal(err)
130 }
131 if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), append(body, '\n'), ""); err != nil {
132 t.Fatal(err)
133 }
134
135 rec, etag, err := Get(ctx, s, "bag", "harbourmail")
136 if err != nil || rec == nil {
137 t.Fatalf("Get = %+v %v", rec, err)
138 }
139 if etag != "" {
140 t.Fatalf("etag = %q, want empty so the next Put is a create", etag)
141 }
142 if rec.Provisioned.KeyID != "AKIA1" {
143 t.Fatalf("the fallback lost the record: %+v", rec)
144 }
145
146 // Put always writes the sender key and never the legacy one.
147 if _, err := Put(ctx, s, "bag", "harbourmail", rec, etag); err != nil {
148 t.Fatalf("Put after the fallback: %v", err)
149 }
150 if _, _, err := s.Get(ctx, Key("bag", "harbourmail")); err != nil {
151 t.Fatalf("senders/app.json was not written: %v", err)
152 }
153 // email.json is left in place, unwritten, for one release.
154 raw, _, err := s.Get(ctx, LegacyKey("bag", "harbourmail"))
155 if err != nil || !bytes.Contains(raw, []byte("AKIA1")) {
156 t.Fatalf("email.json was disturbed: %q %v", raw, err)
157 }
158
159 // Once the sender object exists it is preferred, with a real etag.
160 rec2, etag2, err := Get(ctx, s, "bag", "harbourmail")
161 if err != nil || rec2 == nil || etag2 == "" {
162 t.Fatalf("Get after the migration = %+v %q %v", rec2, etag2, err)
163 }
164 if rec2.Provisioned.KeyID != "AKIA1" || rec2.Declared.From != "noreply@x.com" {
165 t.Fatalf("the migrated record lost a block: %+v", rec2)
166 }
167 // A second writer still holding the fallback's empty etag loses the race
168 // rather than clobbering the migrated object.
169 if _, err := Put(ctx, s, "bag", "harbourmail", rec, ""); !errors.Is(err, release.ErrCASConflict) {
170 t.Fatalf("a create over an existing sender = %v, want ErrCASConflict", err)
171 }
172}
173
174// An app with neither object is "no email", a state and not an error.
175func TestGetWithNothingWrittenIsNotAnError(t *testing.T) {
176 rec, etag, err := Get(context.Background(), release.NewDirStore(t.TempDir()), "bag", "harbourmail")
177 if rec != nil || etag != "" || err != nil {
178 t.Fatalf("Get = %+v %q %v", rec, etag, err)
179 }
180}
181
182// ListSenders answers for an app that has never been migrated, because the
183// status reply reads it on every call and an unmigrated app must not look
184// like an app with no senders at all.
185func TestListSendersAnswersFromTheLegacyRecord(t *testing.T) {
186 ctx := context.Background()
187 s := release.NewDirStore(t.TempDir())
188 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
189 body, _ := json.Marshal(&Record{Schema: Schema, App: "harbourmail",
190 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"}})
191 if _, err := s.Put(ctx, LegacyKey("bag", "harbourmail"), body, ""); err != nil {
192 t.Fatal(err)
193 }
194 got, err := ListSenders(ctx, s, "bag", "harbourmail")
195 if err != nil || len(got) != 1 {
196 t.Fatalf("ListSenders = %+v %v, want one sender", got, err)
197 }
198 if got[0].ID != SenderAppID || got[0].Scope != ScopeApp || got[0].Source != SourceDomain {
199 t.Fatalf("the legacy record did not read back as the app sender: %+v", got[0])
200 }
201}
202
203// errOnGetStore wraps a release.Store and forces Get on one specific key to
204// fail with a non-NotExist error, standing in for a transient store error
205// as distinct from that key being genuinely absent.
206type errOnGetStore struct {
207 release.Store
208 failKey string
209 err error
210}
211
212func (s *errOnGetStore) Get(ctx context.Context, key string) ([]byte, string, error) {
213 if key == s.failKey {
214 return nil, "", s.err
215 }
216 return s.Store.Get(ctx, key)
217}
218
219// ListSenders must not read the legacy record when the app sender's OWN key
220// errored rather than being absent: a store hiccup reading senders/app.json
221// is not proof the app was never migrated, and falling back to email.json
222// would hand the status reply and the resume loop the STALE pre-migration
223// Active/PauseKind instead of an error they can retry against.
224func TestListSendersDoesNotFallBackToLegacyOnAnErroredRead(t *testing.T) {
225 ctx := context.Background()
226 dir := release.NewDirStore(t.TempDir())
227 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
228
229 // The stale, pre-migration record: paused.
230 legacy, err := json.Marshal(&Record{Schema: Schema, App: "harbourmail",
231 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"},
232 Provisioned: &Provisioned{Active: false, PauseKind: PauseKindCap, At: now}})
233 if err != nil {
234 t.Fatal(err)
235 }
236 if _, err := dir.Put(ctx, LegacyKey("bag", "harbourmail"), legacy, ""); err != nil {
237 t.Fatal(err)
238 }
239 // The migrated, current record: active.
240 sn := &Sender{ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
241 Declared: &Declared{EnvPrefix: "HARBOURMAIL_SMTP", From: "noreply@x.com", At: now, By: "o@example.test"},
242 Provisioned: &Provisioned{Active: true, At: now}}
243 if _, err := PutSender(ctx, dir, "bag", "harbourmail", sn, ""); err != nil {
244 t.Fatal(err)
245 }
246
247 failing := &errOnGetStore{
248 Store: dir,
249 failKey: SenderKey("bag", "harbourmail", SenderAppID),
250 err: errors.New("mail_test: injected transient store error"),
251 }
252 got, err := ListSenders(ctx, failing, "bag", "harbourmail")
253 if err == nil {
254 t.Fatalf("ListSenders = %+v, want an error rather than the stale legacy record", got)
255 }
256}
257
258// The Record view's read-modify-write must not lose the fields Record has
259// no name for. Tasks 5 and 7 start populating PerInstance, Identity,
260// Address, TenantARN and Salt directly on the Sender; if sn.record() or
261// rec.sender() forgot one, an ordinary pause would silently clear it.
262// SetPaused is the realistic shape: it reads through Record, changes only
263// the Provisioned block, and writes back through Put.
264func TestGetPutPreservesFieldsRecordHasNoNameFor(t *testing.T) {
265 ctx := context.Background()
266 s, p := release.NewDirStore(t.TempDir()), NewMemProvisioner("1")
267 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
268
269 sn := &Sender{
270 ID: SenderAppID, Scope: ScopeApp, Source: SourceDomain,
271 Provisioned: &Provisioned{IAMUser: "carlos-mail-bag-harbourmail", KeyID: "AKIA1", Active: true, At: now},
272 PerInstance: true,
273 Identity: "arn:aws:ses:eu-west-1:1:identity/harbourmail.bag.oncarlos.com",
274 Address: "noreply@harbourmail.bag.oncarlos.com",
275 TenantARN: "arn:aws:ses:eu-west-1:1:tenant/carlos-bag",
276 Salt: "s3cr3tsalt",
277 }
278 if _, err := PutSender(ctx, s, "bag", "harbourmail", sn, ""); err != nil {
279 t.Fatalf("PutSender: %v", err)
280 }
281
282 if _, err := SetPaused(ctx, s, p, "bag", "harbourmail", true, PauseKindCap, now); err != nil {
283 t.Fatalf("SetPaused: %v", err)
284 }
285
286 got, _, err := GetSender(ctx, s, "bag", "harbourmail", SenderAppID)
287 if err != nil || got == nil {
288 t.Fatalf("GetSender = %+v %v", got, err)
289 }
290 if got.Provisioned == nil || got.Provisioned.Active || got.Provisioned.PauseKind != PauseKindCap {
291 t.Fatalf("SetPaused's own change did not take: %+v", got.Provisioned)
292 }
293 if !got.PerInstance {
294 t.Fatalf("the pause's read-modify-write cleared PerInstance: %+v", got)
295 }
296 if got.Identity != sn.Identity || got.Address != sn.Address || got.TenantARN != sn.TenantARN || got.Salt != sn.Salt {
297 t.Fatalf("the pause's read-modify-write lost a field: %+v", got)
298 }
299}
300