787151245e
解决与 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>
587 lines
18 KiB
Go
587 lines
18 KiB
Go
//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)
|
||
}
|
||
}
|