Compare commits
6 Commits
docs/packa
...
test/tick-
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aae4df2cd | |||
| 530e4ec219 | |||
| 64b6f0d91a | |||
| 5dd196834f | |||
| e85dc7fd4d | |||
| ff562c7427 |
@@ -225,6 +225,23 @@ func ReconcileExpiredCards(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed.
|
||||
// Lazy deletion on read (PayRedirect) is the primary path, this sweep only
|
||||
// reclaims tokens nobody ever followed so the table stays bounded.
|
||||
func ReconcilePayRedirects(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{})
|
||||
if res.Error != nil {
|
||||
reconcileLog().Error("pay_redirects sweep failed", "error", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return
|
||||
}
|
||||
reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected)
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
||||
// number and posts a single Slack reminder. Pure read + notify.
|
||||
func ReconcileUnshipped(ctx context.Context) {
|
||||
|
||||
@@ -518,3 +518,81 @@ func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt t
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate
|
||||
// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt
|
||||
// is still in the future survives. Seeding both sides through GORM keeps the
|
||||
// stored timestamp format identical to the one the sweep binds for time.Now(),
|
||||
// so the comparison is exercised exactly as it runs in production.
|
||||
func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
now := time.Now()
|
||||
expired := models.PayRedirect{
|
||||
Token: "tok_expired",
|
||||
URL: "https://example.test/expired",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: now.Add(-20 * time.Minute),
|
||||
ExpiresAt: now.Add(-10 * time.Minute),
|
||||
}
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeAddCard,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&expired).Error; err != nil {
|
||||
t.Fatalf("seed expired: %v", err)
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var remaining []models.PayRedirect
|
||||
if err := db.Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("list remaining: %v", err)
|
||||
}
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("want 1 row remaining, got %d", len(remaining))
|
||||
}
|
||||
if remaining[0].Token != "tok_live" {
|
||||
t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
live := models.PayRedirect{
|
||||
Token: "tok_live_only",
|
||||
URL: "https://example.test/live",
|
||||
Purpose: models.RedirectPurposeCheckout3DS,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
if err := db.Create(&live).Error; err != nil {
|
||||
t.Fatalf("seed live: %v", err)
|
||||
}
|
||||
|
||||
ReconcilePayRedirects(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("want 1 row preserved, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the Stripe signature. Reject anything that doesn't match.
|
||||
if h.webhookSecret == "" {
|
||||
// No secret configured — log a loud warning but still process in dev.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification")
|
||||
// Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
|
||||
// in production, so the server should never boot into this state; this is
|
||||
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
|
||||
// Slack handlers, which already refuse to run without their secret.
|
||||
if os.Getenv("ENVIRONMENT") == "production" {
|
||||
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
|
||||
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
|
||||
return
|
||||
}
|
||||
// Development only; warn loudly but still process so local testing works.
|
||||
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
|
||||
} else {
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
|
||||
@@ -124,6 +134,20 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
return
|
||||
}
|
||||
|
||||
// Defense in depth: even behind a valid signature, refuse to mark an order
|
||||
// paid unless the PaymentIntent's amount and currency match what we charged.
|
||||
// Under ConvertCart these always match (Amount == order.Total at creation), so
|
||||
// a mismatch means a malformed or unexpected event we should not trust.
|
||||
if pi.Amount != int64(order.Total) {
|
||||
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
|
||||
return
|
||||
}
|
||||
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
|
||||
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
|
||||
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
|
||||
return
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
"status": models.OrderStatusPaid,
|
||||
|
||||
@@ -222,3 +222,38 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
|
||||
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
|
||||
|
||||
// PI claims 1 cent; seed order.Total is 500.
|
||||
pi := map[string]any{
|
||||
"id": order.StripePaymentID,
|
||||
"amount": 1,
|
||||
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
|
||||
}
|
||||
raw, err := json.Marshal(pi)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal pi: %v", err)
|
||||
}
|
||||
evt := stripe.Event{
|
||||
Type: "payment_intent.succeeded",
|
||||
Data: &stripe.EventData{Raw: raw},
|
||||
}
|
||||
|
||||
h := NewWebhookHandler("", "")
|
||||
h.handlePaymentIntentSucceeded(context.Background(), evt)
|
||||
|
||||
db := database.GetDB()
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
t.Fatalf("reloaded order: %v", err)
|
||||
}
|
||||
if reloaded.Status == models.OrderStatusPaid {
|
||||
t.Fatalf("order marked paid despite amount mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
72
api/main.go
72
api/main.go
@@ -25,6 +25,25 @@ import (
|
||||
|
||||
const version = "v0.1.0"
|
||||
|
||||
// everyTick runs fn on its own goroutine every d until ctx is cancelled.
|
||||
// Shared by the reconciler sweeps so the ticker + shutdown plumbing lives in
|
||||
// one place. ReconcileUnshipped intentionally does not use it, it needs a
|
||||
// staggered warm-up run before its first tick.
|
||||
func everyTick(ctx context.Context, d time.Duration, fn func(context.Context)) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(d)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
fn(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
observability.InitLogger()
|
||||
middleware.SetBuildInfo(version)
|
||||
@@ -106,20 +125,8 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start reconciliation job. runs every 5 minutes to catch any orders
|
||||
// that were charged but not recorded due to a crash.
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Catch orders charged but not recorded due to a crash. Every 5 minutes
|
||||
everyTick(ctx, 5*time.Minute, handlers.ReconcileOrders)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
@@ -142,33 +149,18 @@ func main() {
|
||||
|
||||
// Sweep saved cards whose retention deadline elapsed. Read paths filter
|
||||
// expired rows from query results, so this sweeper only drives the Stripe
|
||||
// detach + audit + physical row delete asynchronously.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// detach + audit + physical row delete asynchronously. Hourly.
|
||||
everyTick(ctx, 1*time.Hour, handlers.ReconcileExpiredCards)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Flip abandoned 3DS orders to failed. Every 15 minutes.
|
||||
threshold := time.Duration(cfg.Abandoned3DSThresholdMinutes) * time.Minute
|
||||
everyTick(ctx, 15*time.Minute, func(ctx context.Context) {
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
})
|
||||
|
||||
// Sweep expired pay_redirects rows so the table stays bounded. Lazy delete
|
||||
// on read is the primary path; this only reclaims tokens nobody follows.
|
||||
everyTick(ctx, 10*time.Minute, handlers.ReconcilePayRedirects)
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
|
||||
92
api/main_test.go
Normal file
92
api/main_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEveryTick_CallsFnOnTick verifies the helper invokes fn repeatedly on its
|
||||
// interval. A non-blocking send keeps the spawned goroutine from ever blocking
|
||||
// on a full channel, and the 1s timeout per tick is generous vs the 1ms
|
||||
// interval so the test is not timing-flaky.
|
||||
func TestEveryTick_CallsFnOnTick(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
fired := make(chan struct{}, 1)
|
||||
everyTick(ctx, time.Millisecond, func(context.Context) {
|
||||
select {
|
||||
case fired <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
for i := range 3 {
|
||||
select {
|
||||
case <-fired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("tick %d did not fire within 1s", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryTick_StopsOnContextCancel verifies cancelling ctx stops the loop.
|
||||
// After the first tick we cancel inside the inter-tick gap, so everyTick's
|
||||
// select sees only ctx.Done ready (ticker.C is cold) and returns
|
||||
// deterministically — there is no random pick between two ready cases. We then
|
||||
// confirm no further tick arrives within a full interval, which a live loop
|
||||
// could not satisfy. This is the shutdown guarantee the reconcilers depend on.
|
||||
func TestEveryTick_StopsOnContextCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
const interval = 50 * time.Millisecond
|
||||
ticks := make(chan struct{}) // unbuffered: fn blocks until the test receives
|
||||
everyTick(ctx, interval, func(context.Context) {
|
||||
ticks <- struct{}{}
|
||||
})
|
||||
|
||||
// First tick proves the loop is running and leaves the goroutine back in
|
||||
// select with ticker.C cold for the next ~interval.
|
||||
select {
|
||||
case <-ticks:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("everyTick never called fn")
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
// Cancel landed in the cold gap, so the loop must have taken ctx.Done and
|
||||
// returned. A live loop would tick again at ~interval; a stopped one never
|
||||
// will.
|
||||
select {
|
||||
case <-ticks:
|
||||
t.Fatal("everyTick ticked again after cancel")
|
||||
case <-time.After(interval + 50*time.Millisecond):
|
||||
// good: silent past a full interval — the loop returned
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryTick_PassesContextToFn(t *testing.T) {
|
||||
type ctxKey string
|
||||
const marker ctxKey = "marker"
|
||||
ctx, cancel := context.WithCancel(context.WithValue(context.Background(), marker, "present"))
|
||||
defer cancel()
|
||||
|
||||
got := make(chan string, 1)
|
||||
everyTick(ctx, time.Millisecond, func(c context.Context) {
|
||||
v, _ := c.Value(marker).(string)
|
||||
select {
|
||||
case got <- v:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case v := <-got:
|
||||
if v != "present" {
|
||||
t.Fatalf("fn received wrong context: value = %q, want %q", v, "present")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("fn never ran")
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,7 @@ func (c *Config) validate() error {
|
||||
{"JWT_SECRET", c.JWTSecret},
|
||||
{"STRIPE_SECRET_KEY", c.StripeSecretKey},
|
||||
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
|
||||
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
|
||||
}
|
||||
|
||||
var missing []string
|
||||
|
||||
Reference in New Issue
Block a user