//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) } }