package movementtest import ( "context" "testing" "time" movement "amadan.net/carlos/movement/internal/movement" ) // The budget is what actually stands between a stranger's inbox and a flood // from our own verified sending identity. Proof of work is a speed bump — // 2^18 SHA-256 is under a millisecond on a commodity GPU — so this is the // defence, and it is persisted because the process hibernates and an // in-memory counter would forget on the next wake. func TestAnAddressCannotBeMailedPastItsAllowance(t *testing.T) { h := newApp(t) const addr = "flooded@example.com" for i := 0; i < movement.PerAddressPerDay+3; i++ { postSignup(t, h, submission(t, addr, "curious")) } // Give any in-flight sends a moment to land before counting. time.Sleep(100 * time.Millisecond) if got := h.Mail.countTo(addr); got != movement.PerAddressPerDay { t.Errorf("%d messages reached %s, want the budget of %d", got, addr, movement.PerAddressPerDay) } } // A suppressed send is invisible from the outside. If it were not, the budget // itself would become the oracle the one rule exists to prevent. func TestSuppressedSendRendersTheSamePage(t *testing.T) { h := newApp(t) const addr = "quiet@example.com" first := postSignup(t, h, submission(t, addr, "curious")).Body.String() for i := 0; i < movement.PerAddressPerDay; i++ { postSignup(t, h, submission(t, addr, "curious")) } suppressed := postSignup(t, h, submission(t, addr, "curious")).Body.String() if suppressed != first { t.Error("a submission whose message was suppressed looked different from one that was sent") } } // blocking never returns. mail.Sender.Send discards the context it is given // and smtp.SendMail dials with no deadline, so this is not a hypothetical // sender — it is what a hung SES connection looks like from in here. type blocking struct{ entered chan struct{} } func (b *blocking) Send(ctx context.Context, to, subject, body string) error { select { case b.entered <- struct{}{}: default: } <-ctx.Done() // held until the app's own deadline fires return ctx.Err() } // The send happens after the transaction commits, in a goroutine. Inside the // transaction it would hold the single SQLite writer and stall every other // signup and the scheduled tick behind it. func TestAHungSenderDoesNotBlockTheNextSubmission(t *testing.T) { hung := &blocking{entered: make(chan struct{}, 1)} h := newAppWith(t, hung) postSignup(t, h, submission(t, "first@example.com", "curious")) select { case <-hung.entered: case <-time.After(2 * time.Second): t.Fatal("the sender was never called") } // With the first send still hanging, a second submission must complete. done := make(chan struct{}) go func() { postSignup(t, h, submission(t, "second@example.com", "curious")) close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("a hung send blocked the next submission; it is holding the writer") } if got := countSignups(t, h, "second@example.com"); got != 1 { t.Errorf("the second submission wrote %d rows, want 1", got) } } // The row is committed before the message is attempted, so a send that never // succeeds still leaves something to re-submit against. func TestRowSurvivesASendThatNeverCompletes(t *testing.T) { hung := &blocking{entered: make(chan struct{}, 1)} h := newAppWith(t, hung) postSignup(t, h, submission(t, "stranded@example.com", "curious")) if got := countSignups(t, h, "stranded@example.com"); got != 1 { t.Fatal("the signup was rolled back when its message hung") } if row := loadSignup(t, h, "stranded@example.com"); len(row.TokenHash) == 0 { t.Error("no token was stored, so no future link could ever work") } }