rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "bytes"
5 "context"
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "log/slog"
13 "net/http"
14 "net/http/httptest"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "testing"
19 "time"
20
21 "gorm.io/gorm"
22
23 "amadan.net/rastrillo/abono"
24)
25
26// event is a Stripe event as a delivery carries it.
27func event(id, typ string, object map[string]any) []byte {
28 b, err := json.Marshal(map[string]any{"id": id, "type": typ, "data": map[string]any{"object": object}})
29 if err != nil {
30 panic(err)
31 }
32 return b
33}
34
35// sign is a Stripe-Signature header for body, made at at.
36func sign(secret string, body []byte, at time.Time) string {
37 mac := hmac.New(sha256.New, []byte(secret))
38 fmt.Fprintf(mac, "%d.", at.Unix())
39 mac.Write(body)
40 return fmt.Sprintf("t=%d,v1=%s", at.Unix(), hex.EncodeToString(mac.Sum(nil)))
41}
42
43// deliver posts a body signed with the double's secret at t0.
44func deliver(t *testing.T, s *abono.Service, body []byte) *httptest.ResponseRecorder {
45 t.Helper()
46 return deliverWith(s, body, sign("whsec_double", body, t0))
47}
48
49func deliverWith(s *abono.Service, body []byte, signature string) *httptest.ResponseRecorder {
50 r := httptest.NewRequest(http.MethodPost, abono.WebhookPath, bytes.NewReader(body))
51 r.Header.Set("Stripe-Signature", signature)
52 w := httptest.NewRecorder()
53 s.ServeWebhook(w, r)
54 return w
55}
56
57func wantStatus(t *testing.T, w *httptest.ResponseRecorder, want int) {
58 t.Helper()
59 if w.Code != want {
60 t.Fatalf("status = %d, want %d (body %q)", w.Code, want, w.Body.String())
61 }
62}
63
64const (
65 subsQuery = `SELECT stripe_subscription_id, subject, plan, status, email FROM abono_subscriptions ORDER BY stripe_subscription_id`
66 eventsQuery = `SELECT id, type FROM abono_events ORDER BY id`
67)
68
69func TestWebhookRefusesBadSignature(t *testing.T) {
70 now := t0
71 d := newStripeDouble(t)
72 s, db := d.service(t, &now, abono.Config{})
73 seedPlans(t, s)
74 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)})
75 body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
76
77 for name, header := range map[string]string{
78 "none": "",
79 "wrong secret": sign("whsec_other", body, t0),
80 "stale": sign("whsec_double", body, t0.Add(-time.Hour)),
81 "other body": sign("whsec_double", []byte("{}"), t0),
82 } {
83 wantStatus(t, deliverWith(s, body, header), http.StatusBadRequest)
84 if got := db.dump(eventsQuery); got != "" {
85 t.Fatalf("%s: events recorded:\n%s", name, got)
86 }
87 if got := db.dump(subsQuery); got != "" {
88 t.Fatalf("%s: a subscription was synced:\n%s", name, got)
89 }
90 }
91}
92
93func TestWebhookRefusesAnOversizeBody(t *testing.T) {
94 now := t0
95 d := newStripeDouble(t)
96 s, db := d.service(t, &now, abono.Config{})
97 body := []byte(`{"id":"evt_big","type":"customer.created","pad":"` + strings.Repeat("x", 1<<20) + `"}`)
98 wantStatus(t, deliver(t, s, body), http.StatusBadRequest)
99 if got := db.dump(eventsQuery); got != "" {
100 t.Fatalf("events recorded:\n%s", got)
101 }
102}
103
104func TestWebhookRefusesWhatIsNotAnEvent(t *testing.T) {
105 now := t0
106 d := newStripeDouble(t)
107 s, _ := d.service(t, &now, abono.Config{})
108 for _, body := range []string{"not json", `{"type":"invoice.paid"}`} {
109 wantStatus(t, deliver(t, s, []byte(body)), http.StatusBadRequest)
110 }
111}
112
113func TestWebhookIsIdempotent(t *testing.T) {
114 now := t0
115 d := newStripeDouble(t)
116 rec := &recorder{}
117 s, db := d.service(t, &now, rec.config())
118 seedPlans(t, s)
119 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day),
120 Metadata: map[string]string{"abono_subject": "ana"}})
121 var mu sync.Mutex
122 fetches := 0
123 d.OnSubscriptionFetch(func() { mu.Lock(); fetches++; mu.Unlock() })
124
125 body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
126 wantStatus(t, deliver(t, s, body), http.StatusOK)
127 wantStatus(t, deliver(t, s, body), http.StatusOK)
128
129 mu.Lock()
130 defer mu.Unlock()
131 if fetches != 1 {
132 t.Errorf("the subscription was read %d times, want 1", fetches)
133 }
134 if _, changes := rec.take(); len(changes) != 1 {
135 t.Errorf("OnChange ran %v, want once", changes)
136 }
137 if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" {
138 t.Errorf("events:\n%s", got)
139 }
140 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" {
141 t.Errorf("subscriptions:\n%s", got)
142 }
143}
144
145func TestWebhookUnreadEventIs200(t *testing.T) {
146 now := t0
147 d := newStripeDouble(t)
148 s, db := d.service(t, &now, abono.Config{})
149 var fetched atomic.Bool
150 d.OnSubscriptionFetch(func() { fetched.Store(true) })
151
152 wantStatus(t, deliver(t, s, event("evt_1", "customer.created", map[string]any{"id": "cus_1"})), http.StatusOK)
153 // A checkout session that is not a subscription has none to read.
154 wantStatus(t, deliver(t, s, event("evt_2", "checkout.session.completed", map[string]any{"id": "cs_1", "mode": "payment"})), http.StatusOK)
155 if fetched.Load() {
156 t.Error("a subscription was read for an event that names none")
157 }
158 if got := db.dump(eventsQuery); got != "evt_1|customer.created\nevt_2|checkout.session.completed\n" {
159 t.Errorf("events:\n%s", got)
160 }
161}
162
163func TestWebhookVanishedSubscriptionIs200(t *testing.T) {
164 now := t0
165 d := newStripeDouble(t)
166 s, db := d.service(t, &now, abono.Config{})
167 // The subscription is not in the double: Stripe says it does not exist.
168 wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.deleted", map[string]any{"id": "sub_gone"})), http.StatusOK)
169 if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.deleted\n" {
170 t.Errorf("events:\n%s", got)
171 }
172 if got := db.dump(subsQuery); got != "" {
173 t.Errorf("subscriptions:\n%s", got)
174 }
175}
176
177func TestWebhookSyncFailureIs500AndNotRecorded(t *testing.T) {
178 now := t0
179 d := newStripeDouble(t)
180 var mu sync.Mutex
181 fail := true
182 cfg := abono.Config{OnChange: func(_ context.Context, _ *gorm.DB, _ string) error {
183 mu.Lock()
184 defer mu.Unlock()
185 if fail {
186 return errors.New("the app is down")
187 }
188 return nil
189 }}
190 s, db := d.service(t, &now, cfg)
191 seedPlans(t, s)
192 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day),
193 Metadata: map[string]string{"abono_subject": "ana"}})
194 body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
195
196 wantStatus(t, deliver(t, s, body), http.StatusInternalServerError)
197 if got := db.dump(eventsQuery); got != "" {
198 t.Errorf("a failed delivery was recorded:\n%s", got)
199 }
200 if got := db.dump(subsQuery); got != "" {
201 t.Errorf("a failed sync left its row:\n%s", got)
202 }
203
204 // Stripe retries; the app is back.
205 mu.Lock()
206 fail = false
207 mu.Unlock()
208 wantStatus(t, deliver(t, s, body), http.StatusOK)
209 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|\n" {
210 t.Errorf("subscriptions:\n%s", got)
211 }
212 if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" {
213 t.Errorf("events:\n%s", got)
214 }
215}
216
217func TestWebhookStripeRefusingTheKeyIs500(t *testing.T) {
218 now := t0
219 refuse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
220 writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "Invalid API Key"}})
221 }))
222 defer refuse.Close()
223 var logs bytes.Buffer
224 s, db := newService(t, &now, abono.Config{
225 Key: "rk_test_revoked", WebhookSecrets: []string{"whsec_double"}, StripeBase: refuse.URL,
226 Origin: "https://site.example", Logger: slog.New(slog.NewTextHandler(&logs, nil)),
227 })
228 wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusInternalServerError)
229 if !strings.Contains(logs.String(), "refused the API key") {
230 t.Errorf("the log does not say the key was refused:\n%s", logs.String())
231 }
232 if got := db.dump(eventsQuery); got != "" {
233 t.Errorf("events:\n%s", got)
234 }
235}
236
237func TestWebhookOffIs404(t *testing.T) {
238 now := t0
239 s, db := newService(t, &now, abono.Config{})
240 if s.On() {
241 t.Fatal("service is On with no key")
242 }
243 body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
244 wantStatus(t, deliver(t, s, body), http.StatusNotFound)
245 if got := db.dump(eventsQuery); got != "" {
246 t.Errorf("events:\n%s", got)
247 }
248}
249
250// An updated event for an ended subscription this service never saw
251// creates nothing (R18): no resurrection.
252func TestWebhookUpdatedForAnEndedUnseenSubscriptionCreatesNothing(t *testing.T) {
253 now := t0
254 d := newStripeDouble(t)
255 s, db := d.service(t, &now, abono.Config{})
256 seedPlans(t, s)
257 d.put(t, subSpec{ID: "sub_old", Status: "canceled", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(-day)})
258
259 wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_old"})), http.StatusOK)
260 if got := db.dump(subsQuery); got != "" {
261 t.Errorf("an ended subscription first seen was recorded:\n%s", got)
262 }
263 if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.updated\n" {
264 t.Errorf("events:\n%s", got)
265 }
266}
267
268// A lost created event recovers: the next event for the subscription makes
269// its row (R16).
270func TestWebhookUpdatedForAGrantingUnseenSubscriptionCreatesARow(t *testing.T) {
271 now := t0
272 d := newStripeDouble(t)
273 rec := &recorder{subject: "ana"}
274 s, db := d.service(t, &now, rec.config())
275 seedPlans(t, s)
276 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)})
277
278 wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusOK)
279 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" {
280 t.Errorf("subscriptions:\n%s", got)
281 }
282 if _, changes := rec.take(); len(changes) != 1 || changes[0] != "ana" {
283 t.Errorf("OnChange ran %v, want once for ana", changes)
284 }
285}
286
287func TestWebhookCheckoutCompletedCarriesItsHint(t *testing.T) {
288 now := t0
289 d := newStripeDouble(t)
290 rec := &recorder{}
291 s, db := d.service(t, &now, rec.config())
292 seedPlans(t, s)
293 // The customer has no address on file: only the session knows it.
294 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)})
295
296 body := event("evt_1", "checkout.session.completed", map[string]any{
297 "id": "cs_1", "subscription": "sub_1", "client_reference_id": "ana",
298 "customer_details": map[string]any{"email": "Ana@Example.com"},
299 })
300 wantStatus(t, deliver(t, s, body), http.StatusOK)
301 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" {
302 t.Errorf("subscriptions:\n%s", got)
303 }
304
305 // An unpaid session's later payment is a second event for the same one.
306 body = event("evt_2", "checkout.session.async_payment_succeeded", map[string]any{"id": "cs_1", "subscription": "sub_1", "client_reference_id": "ana"})
307 wantStatus(t, deliver(t, s, body), http.StatusOK)
308}
309
310func TestWebhookInvoiceNamesItsSubscriptionTwoWays(t *testing.T) {
311 now := t0
312 d := newStripeDouble(t)
313 s, db := d.service(t, &now, abono.Config{})
314 seedPlans(t, s)
315 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day),
316 Metadata: map[string]string{"abono_subject": "ana"}})
317 d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day),
318 Metadata: map[string]string{"abono_subject": "bo"}})
319
320 // Before 2025-03-31.basil: "subscription" on the invoice.
321 wantStatus(t, deliver(t, s, event("evt_1", "invoice.paid", map[string]any{"id": "in_1", "subscription": "sub_1"})), http.StatusOK)
322 // From it: parent.subscription_details.
323 wantStatus(t, deliver(t, s, event("evt_2", "invoice.payment_failed", map[string]any{
324 "id": "in_2", "parent": map[string]any{"subscription_details": map[string]any{"subscription": "sub_2"}}})), http.StatusOK)
325
326 want := "sub_1|ana|annual|active|ana@example.com\nsub_2|bo|monthly|active|bo@example.com\n"
327 if got := db.dump(subsQuery); got != want {
328 t.Errorf("subscriptions:\n%s\nwant:\n%s", got, want)
329 }
330}
331
332func TestSyncNeedsTick(t *testing.T) {
333 t.Setenv("CARLOS_ADMIN_TOKEN", "secret-token")
334 now := t0
335 d := newStripeDouble(t)
336 s, db := d.service(t, &now, abono.Config{})
337 seedPlans(t, s)
338 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)})
339
340 call := func(auth string) int {
341 r := httptest.NewRequest(http.MethodPost, abono.SyncPath, nil)
342 if auth != "" {
343 r.Header.Set("Authorization", auth)
344 }
345 w := httptest.NewRecorder()
346 s.ServeSync(w, r)
347 return w.Code
348 }
349 for _, auth := range []string{"", "Bearer wrong"} {
350 if got := call(auth); got != http.StatusForbidden {
351 t.Errorf("Authorization %q: status %d, want 403", auth, got)
352 }
353 }
354 if got := db.dump(subsQuery); got != "" {
355 t.Fatalf("a refused tick reconciled:\n%s", got)
356 }
357 if got := call("Bearer secret-token"); got != http.StatusNoContent {
358 t.Fatalf("with the token: status %d, want 204", got)
359 }
360 if got := db.dump(subsQuery); got != "sub_1||annual|active|ana@example.com\n" {
361 t.Errorf("subscriptions after the tick:\n%s", got)
362 }
363}
364
365func TestSyncFailureIs500(t *testing.T) {
366 t.Setenv("CARLOS_ADMIN_TOKEN", "secret-token")
367 now := t0
368 broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
369 writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"message": "down"}})
370 }))
371 defer broken.Close()
372 s, _ := newService(t, &now, abono.Config{
373 Key: "rk_test_x", WebhookSecrets: []string{"whsec_double"}, StripeBase: broken.URL, Origin: "https://site.example",
374 })
375 r := httptest.NewRequest(http.MethodPost, abono.SyncPath, nil)
376 r.Header.Set("Authorization", "Bearer secret-token")
377 w := httptest.NewRecorder()
378 s.ServeSync(w, r)
379 wantStatus(t, w, http.StatusInternalServerError)
380}
381
382// A deduplication record that cannot be saved is a failed delivery: Stripe
383// must retry, and the retry (sync being idempotent) succeeds.
384func TestWebhookFailedEventRecordIs500AndRetried(t *testing.T) {
385 now := t0
386 d := newStripeDouble(t)
387 rec := &recorder{}
388 s, db := d.service(t, &now, rec.config())
389 seedPlans(t, s)
390 d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day),
391 Metadata: map[string]string{"abono_subject": "ana"}})
392 body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"})
393
394 db.exec(`CREATE TRIGGER no_events BEFORE INSERT ON abono_events BEGIN SELECT RAISE(ABORT, 'disk full'); END`)
395 wantStatus(t, deliver(t, s, body), http.StatusInternalServerError)
396 if got := db.dump(eventsQuery); got != "" {
397 t.Fatalf("events:\n%s", got)
398 }
399
400 db.exec(`DROP TRIGGER no_events`)
401 wantStatus(t, deliver(t, s, body), http.StatusOK)
402 if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" {
403 t.Errorf("events after the retry:\n%s", got)
404 }
405 if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" {
406 t.Errorf("subscriptions:\n%s", got)
407 }
408 if _, changes := rec.take(); len(changes) != 1 {
409 t.Errorf("OnChange ran %v, want once (the retry found nothing to change)", changes)
410 }
411}
412