Files
pangolin/server/internal/codes/service_test.go
T
wangjia 787151245e 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>
2026-06-13 03:03:11 +08:00

587 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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)
}
}