package pay import ( "context" "net/http" "strings" "testing" "time" ) // -------------------------------------------------------------------------- // C2 · promo 限购 TOCTOU: CreateOrder's HasPaidPurchase check is a bare, // unlocked SELECT at order-creation time — concurrent/multi-pending orders // for the same promo SKU can both reach 'created' before either settles. // webhook.go's settle must re-check inside the locked transaction, before // granting, and swallow the duplicate (skip the grant, still ACK so pay // doesn't retry forever) rather than double-granting +31 days per order. // -------------------------------------------------------------------------- // seedPromoDuplicateOrders inserts two 'created' orders for the SAME user + // promo SKU (pro_month_promo) — simulating the TOCTOU window where both // orders were created before either was paid. func seedPromoDuplicateOrders(t *testing.T, st *Store) { t.Helper() ctx := context.Background() if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-1", "alipay", 600, "CNY"); err != nil { t.Fatalf("insert promo order 1: %v", err) } if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-2", "wxpay", 600, "CNY"); err != nil { t.Fatalf("insert promo order 2: %v", err) } } func TestWebhook_PromoDuplicateSettleSkipsGrantButAcks(t *testing.T) { h, db, st := newWebhookRig(t) seedPromoDuplicateOrders(t, st) // First promo order settles normally: paid + granted. w1 := deliver(t, h, succeededPayload("pay-promo-1", "pro_month_promo")) if w1.Code != http.StatusOK || !strings.Contains(w1.Body.String(), "SUCCESS") { t.Fatalf("第一单应正常开通: %d %q", w1.Code, w1.Body.String()) } var expiresAfterFirst time.Time if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterFirst); err != nil { t.Fatalf("第一单未开通订阅: %v", err) } // Second promo order for the SAME user+SKU settles (TOCTOU duplicate): // must still ACK 200 SUCCESS (else pay retries this webhook forever), // but must NOT grant a second +31 days. w2 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) if w2.Code != http.StatusOK || !strings.Contains(w2.Body.String(), "SUCCESS") { t.Fatalf("重复 promo 单应仍 ACK(否则 pay 会无限重投): %d %q", w2.Code, w2.Body.String()) } var n int if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("不得为重复 promo 单二次开通: subscriptions rows = %d, want 1", n) } var expiresAfterSecond time.Time if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterSecond); err != nil { t.Fatal(err) } if !expiresAfterFirst.Equal(expiresAfterSecond) { t.Errorf("到期被重复叠加: %v → %v(应保持不变,promo 限购一次)", expiresAfterFirst, expiresAfterSecond) } // The duplicate order's own ledger row must not become a second 'paid' // row for the same (user_id, sku) — that's exactly what migration 000027's // partial unique index forbids on sqlite, and what the settle-side // re-check must avoid ever attempting. row2, err := st.GetForUser(context.Background(), 1, "pay-promo-2") if err != nil { t.Fatalf("重复单台账未找到: %v", err) } if row2.Status == "paid" { t.Errorf("重复 promo 单不应被标记为二条 paid(会撞 (user_id,sku) 唯一索引): status = %q", row2.Status) } if row2.SubID.Valid { t.Errorf("重复 promo 单不应挂 sub_id: %+v", row2.SubID) } // Redelivery of the SAME duplicate webhook (new nonce, same out_trade_no) // must remain idempotent — no further side effects, still ACK. w3 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) if w3.Code != http.StatusOK || !strings.Contains(w3.Body.String(), "SUCCESS") { t.Fatalf("重复单再次重投应仍 ACK: %d %q", w3.Code, w3.Body.String()) } if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("重投重复单不得二次开通: subscriptions rows = %d, want 1", n) } } // -------------------------------------------------------------------------- // Defense-in-depth: migration 000027's sqlite partial unique index directly // forbids two 'paid' rows for the same (user_id, sku='pro_month_promo'), // independent of the application-layer settle check above. This guards // against any other write path (bug, manual SQL, future code) accidentally // double-marking a promo purchase 'paid'. // -------------------------------------------------------------------------- func TestPayPurchases_PromoPaidUniqueIndex_SQLite(t *testing.T) { db := openMigratedSQLite(t) seedUser(t, db, 1, "uuid-1") now := time.Now().UTC() if _, err := db.Exec( `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-1', 'alipay', 'paid', 600, 'CNY', ?, ?)`, now, now); err != nil { t.Fatalf("first paid promo row should insert cleanly: %v", err) } _, err := db.Exec( `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-2', 'wxpay', 'paid', 600, 'CNY', ?, ?)`, now, now) if err == nil { t.Fatal("第二条同 user+promo-sku 的 paid 行应被部分唯一索引拒绝,却插入成功") } if !strings.Contains(err.Error(), "UNIQUE") { t.Errorf("err = %v, want a UNIQUE constraint violation", err) } // A non-promo SKU (or a 'created'/'canceled' status row) must be // unaffected by the partial index — sanity check it isn't over-broad. if _, err := db.Exec( `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) VALUES (1, 'uuid-1', 'pro_month', 'ux-3', 'alipay', 'paid', 4990000, 'USDT', ?, ?)`, now, now); err != nil { t.Errorf("非 promo SKU 的 paid 行不应受此索引影响: %v", err) } }