package abono_test import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono" ) // event is a Stripe event as a delivery carries it. func event(id, typ string, object map[string]any) []byte { b, err := json.Marshal(map[string]any{"id": id, "type": typ, "data": map[string]any{"object": object}}) if err != nil { panic(err) } return b } // sign is a Stripe-Signature header for body, made at at. func sign(secret string, body []byte, at time.Time) string { mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "%d.", at.Unix()) mac.Write(body) return fmt.Sprintf("t=%d,v1=%s", at.Unix(), hex.EncodeToString(mac.Sum(nil))) } // deliver posts a body signed with the double's secret at t0. func deliver(t *testing.T, s *abono.Service, body []byte) *httptest.ResponseRecorder { t.Helper() return deliverWith(s, body, sign("whsec_double", body, t0)) } func deliverWith(s *abono.Service, body []byte, signature string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, abono.WebhookPath, bytes.NewReader(body)) r.Header.Set("Stripe-Signature", signature) w := httptest.NewRecorder() s.ServeWebhook(w, r) return w } func wantStatus(t *testing.T, w *httptest.ResponseRecorder, want int) { t.Helper() if w.Code != want { t.Fatalf("status = %d, want %d (body %q)", w.Code, want, w.Body.String()) } } const ( subsQuery = `SELECT stripe_subscription_id, subject, plan, status, email FROM abono_subscriptions ORDER BY stripe_subscription_id` eventsQuery = `SELECT id, type FROM abono_events ORDER BY id` ) func TestWebhookRefusesBadSignature(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)}) body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"}) for name, header := range map[string]string{ "none": "", "wrong secret": sign("whsec_other", body, t0), "stale": sign("whsec_double", body, t0.Add(-time.Hour)), "other body": sign("whsec_double", []byte("{}"), t0), } { wantStatus(t, deliverWith(s, body, header), http.StatusBadRequest) if got := db.dump(eventsQuery); got != "" { t.Fatalf("%s: events recorded:\n%s", name, got) } if got := db.dump(subsQuery); got != "" { t.Fatalf("%s: a subscription was synced:\n%s", name, got) } } } func TestWebhookRefusesAnOversizeBody(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) body := []byte(`{"id":"evt_big","type":"customer.created","pad":"` + strings.Repeat("x", 1<<20) + `"}`) wantStatus(t, deliver(t, s, body), http.StatusBadRequest) if got := db.dump(eventsQuery); got != "" { t.Fatalf("events recorded:\n%s", got) } } func TestWebhookRefusesWhatIsNotAnEvent(t *testing.T) { now := t0 d := newStripeDouble(t) s, _ := d.service(t, &now, abono.Config{}) for _, body := range []string{"not json", `{"type":"invoice.paid"}`} { wantStatus(t, deliver(t, s, []byte(body)), http.StatusBadRequest) } } func TestWebhookIsIdempotent(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "ana"}}) var mu sync.Mutex fetches := 0 d.OnSubscriptionFetch(func() { mu.Lock(); fetches++; mu.Unlock() }) body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"}) wantStatus(t, deliver(t, s, body), http.StatusOK) wantStatus(t, deliver(t, s, body), http.StatusOK) mu.Lock() defer mu.Unlock() if fetches != 1 { t.Errorf("the subscription was read %d times, want 1", fetches) } if _, changes := rec.take(); len(changes) != 1 { t.Errorf("OnChange ran %v, want once", changes) } if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" { t.Errorf("events:\n%s", got) } if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" { t.Errorf("subscriptions:\n%s", got) } } func TestWebhookUnreadEventIs200(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) var fetched atomic.Bool d.OnSubscriptionFetch(func() { fetched.Store(true) }) wantStatus(t, deliver(t, s, event("evt_1", "customer.created", map[string]any{"id": "cus_1"})), http.StatusOK) // A checkout session that is not a subscription has none to read. wantStatus(t, deliver(t, s, event("evt_2", "checkout.session.completed", map[string]any{"id": "cs_1", "mode": "payment"})), http.StatusOK) if fetched.Load() { t.Error("a subscription was read for an event that names none") } if got := db.dump(eventsQuery); got != "evt_1|customer.created\nevt_2|checkout.session.completed\n" { t.Errorf("events:\n%s", got) } } func TestWebhookVanishedSubscriptionIs200(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) // The subscription is not in the double: Stripe says it does not exist. wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.deleted", map[string]any{"id": "sub_gone"})), http.StatusOK) if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.deleted\n" { t.Errorf("events:\n%s", got) } if got := db.dump(subsQuery); got != "" { t.Errorf("subscriptions:\n%s", got) } } func TestWebhookSyncFailureIs500AndNotRecorded(t *testing.T) { now := t0 d := newStripeDouble(t) var mu sync.Mutex fail := true cfg := abono.Config{OnChange: func(_ context.Context, _ *gorm.DB, _ string) error { mu.Lock() defer mu.Unlock() if fail { return errors.New("the app is down") } return nil }} s, db := d.service(t, &now, cfg) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "ana"}}) body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"}) wantStatus(t, deliver(t, s, body), http.StatusInternalServerError) if got := db.dump(eventsQuery); got != "" { t.Errorf("a failed delivery was recorded:\n%s", got) } if got := db.dump(subsQuery); got != "" { t.Errorf("a failed sync left its row:\n%s", got) } // Stripe retries; the app is back. mu.Lock() fail = false mu.Unlock() wantStatus(t, deliver(t, s, body), http.StatusOK) if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|\n" { t.Errorf("subscriptions:\n%s", got) } if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" { t.Errorf("events:\n%s", got) } } func TestWebhookStripeRefusingTheKeyIs500(t *testing.T) { now := t0 refuse := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "Invalid API Key"}}) })) defer refuse.Close() var logs bytes.Buffer s, db := newService(t, &now, abono.Config{ Key: "rk_test_revoked", WebhookSecrets: []string{"whsec_double"}, StripeBase: refuse.URL, Origin: "https://site.example", Logger: slog.New(slog.NewTextHandler(&logs, nil)), }) wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusInternalServerError) if !strings.Contains(logs.String(), "refused the API key") { t.Errorf("the log does not say the key was refused:\n%s", logs.String()) } if got := db.dump(eventsQuery); got != "" { t.Errorf("events:\n%s", got) } } func TestWebhookOffIs404(t *testing.T) { now := t0 s, db := newService(t, &now, abono.Config{}) if s.On() { t.Fatal("service is On with no key") } body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"}) wantStatus(t, deliver(t, s, body), http.StatusNotFound) if got := db.dump(eventsQuery); got != "" { t.Errorf("events:\n%s", got) } } // An updated event for an ended subscription this service never saw // creates nothing (R18): no resurrection. func TestWebhookUpdatedForAnEndedUnseenSubscriptionCreatesNothing(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) d.put(t, subSpec{ID: "sub_old", Status: "canceled", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(-day)}) wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_old"})), http.StatusOK) if got := db.dump(subsQuery); got != "" { t.Errorf("an ended subscription first seen was recorded:\n%s", got) } if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.updated\n" { t.Errorf("events:\n%s", got) } } // A lost created event recovers: the next event for the subscription makes // its row (R16). func TestWebhookUpdatedForAGrantingUnseenSubscriptionCreatesARow(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "ana"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) wantStatus(t, deliver(t, s, event("evt_1", "customer.subscription.updated", map[string]any{"id": "sub_1"})), http.StatusOK) if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" { t.Errorf("subscriptions:\n%s", got) } if _, changes := rec.take(); len(changes) != 1 || changes[0] != "ana" { t.Errorf("OnChange ran %v, want once for ana", changes) } } func TestWebhookCheckoutCompletedCarriesItsHint(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) // The customer has no address on file: only the session knows it. d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", End: t0.Add(30 * day)}) body := event("evt_1", "checkout.session.completed", map[string]any{ "id": "cs_1", "subscription": "sub_1", "client_reference_id": "ana", "customer_details": map[string]any{"email": "Ana@Example.com"}, }) wantStatus(t, deliver(t, s, body), http.StatusOK) if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" { t.Errorf("subscriptions:\n%s", got) } // An unpaid session's later payment is a second event for the same one. body = event("evt_2", "checkout.session.async_payment_succeeded", map[string]any{"id": "cs_1", "subscription": "sub_1", "client_reference_id": "ana"}) wantStatus(t, deliver(t, s, body), http.StatusOK) } func TestWebhookInvoiceNamesItsSubscriptionTwoWays(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "ana"}}) d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "bo"}}) // Before 2025-03-31.basil: "subscription" on the invoice. wantStatus(t, deliver(t, s, event("evt_1", "invoice.paid", map[string]any{"id": "in_1", "subscription": "sub_1"})), http.StatusOK) // From it: parent.subscription_details. wantStatus(t, deliver(t, s, event("evt_2", "invoice.payment_failed", map[string]any{ "id": "in_2", "parent": map[string]any{"subscription_details": map[string]any{"subscription": "sub_2"}}})), http.StatusOK) want := "sub_1|ana|annual|active|ana@example.com\nsub_2|bo|monthly|active|bo@example.com\n" if got := db.dump(subsQuery); got != want { t.Errorf("subscriptions:\n%s\nwant:\n%s", got, want) } } func TestSyncNeedsTick(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "secret-token") now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) call := func(auth string) int { r := httptest.NewRequest(http.MethodPost, abono.SyncPath, nil) if auth != "" { r.Header.Set("Authorization", auth) } w := httptest.NewRecorder() s.ServeSync(w, r) return w.Code } for _, auth := range []string{"", "Bearer wrong"} { if got := call(auth); got != http.StatusForbidden { t.Errorf("Authorization %q: status %d, want 403", auth, got) } } if got := db.dump(subsQuery); got != "" { t.Fatalf("a refused tick reconciled:\n%s", got) } if got := call("Bearer secret-token"); got != http.StatusNoContent { t.Fatalf("with the token: status %d, want 204", got) } if got := db.dump(subsQuery); got != "sub_1||annual|active|ana@example.com\n" { t.Errorf("subscriptions after the tick:\n%s", got) } } func TestSyncFailureIs500(t *testing.T) { t.Setenv("CARLOS_ADMIN_TOKEN", "secret-token") now := t0 broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"message": "down"}}) })) defer broken.Close() s, _ := newService(t, &now, abono.Config{ Key: "rk_test_x", WebhookSecrets: []string{"whsec_double"}, StripeBase: broken.URL, Origin: "https://site.example", }) r := httptest.NewRequest(http.MethodPost, abono.SyncPath, nil) r.Header.Set("Authorization", "Bearer secret-token") w := httptest.NewRecorder() s.ServeSync(w, r) wantStatus(t, w, http.StatusInternalServerError) } // A deduplication record that cannot be saved is a failed delivery: Stripe // must retry, and the retry (sync being idempotent) succeeds. func TestWebhookFailedEventRecordIs500AndRetried(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "ana"}}) body := event("evt_1", "customer.subscription.created", map[string]any{"id": "sub_1"}) db.exec(`CREATE TRIGGER no_events BEFORE INSERT ON abono_events BEGIN SELECT RAISE(ABORT, 'disk full'); END`) wantStatus(t, deliver(t, s, body), http.StatusInternalServerError) if got := db.dump(eventsQuery); got != "" { t.Fatalf("events:\n%s", got) } db.exec(`DROP TRIGGER no_events`) wantStatus(t, deliver(t, s, body), http.StatusOK) if got := db.dump(eventsQuery); got != "evt_1|customer.subscription.created\n" { t.Errorf("events after the retry:\n%s", got) } if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" { t.Errorf("subscriptions:\n%s", got) } if _, changes := rec.take(); len(changes) != 1 { t.Errorf("OnChange ran %v, want once (the retry found nothing to change)", changes) } }