merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块
解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server (codes 分支原用 pangolin/server,7 个源文件 import 已改写); go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入 build-codegen/test-unit/test-integration target。 go build/vet 通过,codes 单测通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
//go:build integration
|
||||
|
||||
package codes_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// MySQL driver registration.
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Container setup helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func setupMySQL(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
ctr, err := tcmysql.Run(ctx, "mysql:8.0",
|
||||
tcmysql.WithDatabase("pangolin_test"),
|
||||
tcmysql.WithUsername("root"),
|
||||
tcmysql.WithPassword("test"),
|
||||
)
|
||||
testcontainers.CleanupContainer(t, ctr)
|
||||
if err != nil {
|
||||
t.Fatalf("mysql container: %v", err)
|
||||
}
|
||||
|
||||
dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql dsn: %v", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open mysql: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
if err := applySchema(db); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func setupRedis(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
ctr, err := tcredis.Run(ctx, "redis:7-alpine")
|
||||
testcontainers.CleanupContainer(t, ctr)
|
||||
if err != nil {
|
||||
t.Fatalf("redis container: %v", err)
|
||||
}
|
||||
|
||||
addr, err := ctr.ConnectionString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("redis addr: %v", err)
|
||||
}
|
||||
// Remove the redis:// scheme prefix if present.
|
||||
addr = stripScheme(addr)
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return rdb
|
||||
}
|
||||
|
||||
func stripScheme(s string) string {
|
||||
for _, prefix := range []string{"redis://", "rediss://"} {
|
||||
if len(s) > len(prefix) && s[:len(prefix)] == prefix {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// applySchema runs the minimum DDL required by the codes package.
|
||||
func applySchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS plans (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
code ENUM('free','pro','team') NOT NULL UNIQUE,
|
||||
max_devices INT NOT NULL DEFAULT 1,
|
||||
daily_minutes INT NULL,
|
||||
ad_gate BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS code_batches (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel ENUM('store','tg','line','manual') NOT NULL,
|
||||
created_by VARCHAR(64) NOT NULL,
|
||||
note VARCHAR(255) NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS codes (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
code_hash CHAR(64) NOT NULL UNIQUE,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
duration_days INT NOT NULL,
|
||||
batch_id BIGINT UNSIGNED NOT NULL,
|
||||
status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
|
||||
redeemed_by BIGINT UNSIGNED NULL,
|
||||
redeemed_at DATETIME(6) NULL,
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id),
|
||||
FOREIGN KEY (batch_id) REFERENCES code_batches(id),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
expires_at DATETIME(6) NOT NULL,
|
||||
source ENUM('trial','code') NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id),
|
||||
INDEX idx_user_exp (user_id, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
actor VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
target VARCHAR(128) NOT NULL,
|
||||
meta JSON NULL,
|
||||
at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
INDEX idx_at (at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
|
||||
// Seed plan rows.
|
||||
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
||||
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertCode inserts a test code directly into the DB and returns it.
|
||||
func insertCode(t *testing.T, db *sql.DB, plaintext string, plan codes.PlanCode, durationDays int) {
|
||||
t.Helper()
|
||||
canonical, err := codes.Canonicalize(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Canonicalize: %v", err)
|
||||
}
|
||||
hash := codes.Hash(canonical)
|
||||
|
||||
var planID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, string(plan)).Scan(&planID); err != nil {
|
||||
t.Fatalf("plan lookup: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO code_batches (channel, created_by, created_at) VALUES ('manual','test',UTC_TIMESTAMP(6))`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("batch insert: %v", err)
|
||||
}
|
||||
var batchID int64
|
||||
db.QueryRow(`SELECT LAST_INSERT_ID()`).Scan(&batchID)
|
||||
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id) VALUES (?,?,?,?)`,
|
||||
hash, planID, durationDays, batchID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("code insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration tests
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestRedeemConcurrentSingleWinner verifies that when N goroutines try to
|
||||
// redeem the same code simultaneously, exactly 1 succeeds and N-1 fail.
|
||||
func TestRedeemConcurrentSingleWinner(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
const N = 20
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanPro, 30)
|
||||
|
||||
results := make([]bool, N) // true = success
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
userID := int64(1000 + idx) // different user for each goroutine
|
||||
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: code,
|
||||
})
|
||||
results[idx] = (apiErr == nil)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
winners := 0
|
||||
for _, ok := range results {
|
||||
if ok {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Errorf("expected exactly 1 winner, got %d", winners)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedeemIdempotent verifies that the same user re-submitting the same
|
||||
// code gets a 200 with Idempotent=true.
|
||||
func TestRedeemIdempotent(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanPro, 30)
|
||||
|
||||
const userID = int64(2001)
|
||||
|
||||
// First redemption.
|
||||
r1, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("first redeem failed: %v", apiErr)
|
||||
}
|
||||
if r1.Idempotent {
|
||||
t.Error("first redeem should not be idempotent")
|
||||
}
|
||||
|
||||
// Second redemption – same user, same code.
|
||||
r2, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
|
||||
if apiErr2 != nil {
|
||||
t.Fatalf("second redeem failed: %v", apiErr2)
|
||||
}
|
||||
if !r2.Idempotent {
|
||||
t.Error("second redeem should be idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedeemOtherUserFails verifies that a different user trying to redeem an
|
||||
// already-redeemed code gets CODE_REDEEMED and that the failure is counted.
|
||||
func TestRedeemOtherUserFails(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanPro, 30)
|
||||
|
||||
// User A redeems.
|
||||
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3001, Code: code})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("user A redeem: %v", apiErr)
|
||||
}
|
||||
|
||||
// User B tries the same code.
|
||||
_, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3002, Code: code})
|
||||
if apiErr2 == nil {
|
||||
t.Fatal("user B should have failed")
|
||||
}
|
||||
if apiErr2.Code != "CODE_REDEEMED" {
|
||||
t.Errorf("expected CODE_REDEEMED, got %s", apiErr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedeemFailLock verifies that 5 consecutive failures lock the account for
|
||||
// 1 hour and subsequent attempts return ACCOUNT_LOCKED.
|
||||
func TestRedeemFailLock(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
// Use a very short lock for the test (we won't wait).
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
const userID = int64(4001)
|
||||
// Attempt 5 redemptions with invalid codes.
|
||||
for i := 0; i < 5; i++ {
|
||||
fakeCode, _ := codes.GenerateCode() // valid format but not in DB
|
||||
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: fakeCode,
|
||||
})
|
||||
if apiErr == nil {
|
||||
t.Fatalf("attempt %d: expected failure", i+1)
|
||||
}
|
||||
if apiErr.Code == "ACCOUNT_LOCKED" {
|
||||
t.Fatalf("locked too early at attempt %d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th attempt should return ACCOUNT_LOCKED.
|
||||
fakeCode, _ := codes.GenerateCode()
|
||||
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: fakeCode,
|
||||
})
|
||||
if apiErr == nil || apiErr.Code != "ACCOUNT_LOCKED" {
|
||||
t.Errorf("expected ACCOUNT_LOCKED, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSamePlanExtension verifies that redeeming a pro code when the user
|
||||
// already has a pro subscription extends the existing subscription.
|
||||
func TestSamePlanExtension(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
const userID = int64(5001)
|
||||
|
||||
// Pre-insert a pro subscription expiring in 10 days.
|
||||
var planID int64
|
||||
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&planID)
|
||||
initial := time.Now().UTC().AddDate(0, 0, 10)
|
||||
db.Exec(
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,'trial')`,
|
||||
userID, planID, initial,
|
||||
)
|
||||
|
||||
// Redeem a 30-day pro code.
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanPro, 30)
|
||||
|
||||
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: code,
|
||||
})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
|
||||
// expires_at should be initial + 30 days (since initial > now).
|
||||
wantExpiry := initial.AddDate(0, 0, 30)
|
||||
diff := result.ExpiresAt.Sub(wantExpiry)
|
||||
if diff < -2*time.Second || diff > 2*time.Second {
|
||||
t.Errorf("expires_at = %v, want ≈%v (diff=%v)", result.ExpiresAt, wantExpiry, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlanNewSubscription verifies that redeeming a team code when the
|
||||
// user only has a pro subscription creates a new team subscription.
|
||||
func TestCrossPlanNewSubscription(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
const userID = int64(6001)
|
||||
|
||||
// Pre-insert a pro subscription.
|
||||
var proID int64
|
||||
db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID)
|
||||
db.Exec(
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,'2099-01-01','trial')`,
|
||||
userID, proID,
|
||||
)
|
||||
|
||||
// Redeem a 30-day team code.
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanTeam, 30)
|
||||
|
||||
result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: code,
|
||||
})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
|
||||
// Should have created a new team subscription starting from now + 30 days.
|
||||
wantMin := time.Now().UTC().AddDate(0, 0, 29)
|
||||
wantMax := time.Now().UTC().AddDate(0, 0, 31)
|
||||
if result.ExpiresAt.Before(wantMin) || result.ExpiresAt.After(wantMax) {
|
||||
t.Errorf("team expires_at = %v, want within [%v, %v]", result.ExpiresAt, wantMin, wantMax)
|
||||
}
|
||||
|
||||
// Confirm the new row is a team subscription.
|
||||
var count int
|
||||
var teamID int64
|
||||
db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID)
|
||||
db.QueryRow(
|
||||
`SELECT COUNT(1) FROM subscriptions WHERE user_id=? AND plan_id=? AND source='code'`,
|
||||
userID, teamID,
|
||||
).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 new team subscription, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditLogWritten verifies that every successful redemption produces an
|
||||
// audit_log row.
|
||||
func TestAuditLogWritten(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
const userID = int64(7001)
|
||||
code, _ := codes.GenerateCode()
|
||||
insertCode(t, db, code, codes.PlanPro, 30)
|
||||
|
||||
_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
|
||||
UserID: userID,
|
||||
Code: code,
|
||||
})
|
||||
if apiErr != nil {
|
||||
t.Fatalf("redeem: %v", apiErr)
|
||||
}
|
||||
|
||||
var count int
|
||||
db.QueryRow(
|
||||
`SELECT COUNT(1) FROM audit_log WHERE action='redeem' AND actor=?`,
|
||||
fmt.Sprintf("user:%d", userID),
|
||||
).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 audit_log row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVExportNoPlaintextInDB verifies that after a batch generation and
|
||||
// CSV export, no plaintext code is stored in the database.
|
||||
func TestCSVExportNoPlaintextInDB(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, rdb, 5, time.Hour)
|
||||
|
||||
result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
|
||||
PlanCode: codes.PlanPro,
|
||||
DurationDays: 30,
|
||||
Count: 10,
|
||||
Channel: codes.ChannelManual,
|
||||
Note: "integration test",
|
||||
CreatedBy: "test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBatch: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Codes) != 10 {
|
||||
t.Fatalf("expected 10 codes, got %d", len(result.Codes))
|
||||
}
|
||||
|
||||
// Write CSV.
|
||||
var buf bytes.Buffer
|
||||
rows := codes.BatchResultToCSVRows(result, time.Now())
|
||||
if err := codes.ExportCSV(&buf, rows); err != nil {
|
||||
t.Fatalf("ExportCSV: %v", err)
|
||||
}
|
||||
|
||||
// Verify no plaintext appears in the codes table.
|
||||
for _, c := range result.Codes {
|
||||
var count int
|
||||
// The table stores only code_hash; search for the plaintext as a string.
|
||||
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, c).Scan(&count)
|
||||
if count > 0 {
|
||||
t.Errorf("plaintext code %s found in codes.code_hash column!", c)
|
||||
}
|
||||
// Also verify the hash IS present.
|
||||
h := codes.Hash(c)
|
||||
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, h).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("hash for code %s not found in DB", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNonceReplay verifies that replaying a webhook nonce is rejected
|
||||
// with status 200 (duplicate_ignored) and does NOT create a second DB row.
|
||||
func TestWebhookNonceReplay(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
secret := "integration-secret"
|
||||
|
||||
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
|
||||
|
||||
code, _ := codes.GenerateCode()
|
||||
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
|
||||
body, _ := json.Marshal(payload)
|
||||
nonce := fmt.Sprintf("unique-nonce-%d", time.Now().UnixNano())
|
||||
|
||||
makeReq := func() *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
|
||||
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
r.Header.Set("X-Pangolin-Nonce", nonce)
|
||||
return r
|
||||
}
|
||||
|
||||
// First request: should succeed with 201.
|
||||
w1 := httptest.NewRecorder()
|
||||
h.ServeHTTP(w1, makeReq())
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Errorf("first request: expected 201, got %d body=%s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// Second request with same nonce: should return 200 duplicate_ignored.
|
||||
w2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(w2, makeReq())
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Errorf("replay request: expected 200, got %d body=%s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// Only one row should exist in codes.
|
||||
hash := codes.Hash(code)
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 code row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookSameHashIdempotent verifies that pushing the same code_hash twice
|
||||
// (different nonce) is idempotent: no duplicate row is created.
|
||||
func TestWebhookSameHashIdempotent(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
store := codes.NewStore(db)
|
||||
secret := "integration-secret-2"
|
||||
|
||||
h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)
|
||||
|
||||
code, _ := codes.GenerateCode()
|
||||
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
sendReq := func(nonce string) int {
|
||||
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
|
||||
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
r.Header.Set("X-Pangolin-Nonce", nonce)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
status1 := sendReq(fmt.Sprintf("nonce-a-%d", time.Now().UnixNano()))
|
||||
if status1 != http.StatusCreated {
|
||||
t.Fatalf("first request: expected 201, got %d", status1)
|
||||
}
|
||||
|
||||
status2 := sendReq(fmt.Sprintf("nonce-b-%d", time.Now().UnixNano()))
|
||||
if status2 != http.StatusOK {
|
||||
t.Fatalf("second (same hash) request: expected 200, got %d", status2)
|
||||
}
|
||||
|
||||
// Still only one row.
|
||||
hash := codes.Hash(code)
|
||||
var count int
|
||||
db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 code row after idempotent push, got %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user