feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)

Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:16:16 +08:00
parent a642bf16a2
commit afcd7b325c
17 changed files with 2750 additions and 0 deletions
+198
View File
@@ -0,0 +1,198 @@
package codes_test
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"pangolin/server/internal/codes"
)
// --- HMAC helper used across tests ---
func signBody(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
// newWebhookHandlerNoRedis creates a WebhookHandler with a nil Redis client.
// Safe only for tests where the handler returns before the nonce check.
func newWebhookHandlerNoRedis(secret string) *codes.WebhookHandler {
return codes.NewWebhookHandler(nil, nil, secret, 5*time.Minute, 15*time.Minute)
}
// --- Signature validation tests (no Redis required) ---
// TestWebhookSignatureRejected verifies that a bad HMAC causes a 401.
// The signature check fires before any Redis or DB access.
func TestWebhookSignatureRejected(t *testing.T) {
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
// Wrong secret.
r.Header.Set("X-Pangolin-Signature", signBody("wrong-secret", body))
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-1")
h := newWebhookHandlerNoRedis("correct-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
}
assertErrorCode(t, w, "WEBHOOK_INVALID_SIGNATURE")
}
// TestWebhookMissingSignatureHeader verifies that a missing signature causes a 401.
func TestWebhookMissingSignatureHeader(t *testing.T) {
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
// No X-Pangolin-Signature.
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-nosig")
h := newWebhookHandlerNoRedis("any-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// TestWebhookTimestampRejected verifies that a stale timestamp causes a 401.
// The timestamp check fires after signature but before Redis.
func TestWebhookTimestampRejected(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
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))
// Timestamp is 10 minutes in the past outside ±5 min window.
staleTs := time.Now().Add(-10 * time.Minute).Unix()
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(staleTs, 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-stale")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String())
}
assertErrorCode(t, w, "WEBHOOK_TIMESTAMP_EXPIRED")
}
// TestWebhookFutureTimestampRejected verifies that a far-future timestamp is also rejected.
func TestWebhookFutureTimestampRejected(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
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))
futureTs := time.Now().Add(10 * time.Minute).Unix()
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(futureTs, 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-future")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// TestWebhookMissingNonce verifies that a missing nonce causes a 400.
// Missing nonce is checked before Redis access (nil nonce → BadRequest).
func TestWebhookMissingNonce(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
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))
// No X-Pangolin-Nonce header.
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d; body=%s", w.Code, w.Body.String())
}
}
// TestWebhookHMACConstantTime verifies that a correct-length HMAC with wrong
// bytes is still rejected i.e., hmac.Equal (constant-time) is used, not ==.
func TestWebhookHMACConstantTime(t *testing.T) {
secret := "test-secret"
code, _ := codes.GenerateCode()
payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
body, _ := json.Marshal(payload)
// Produce a MAC of the correct length but with the last byte flipped.
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
correctMAC := mac.Sum(nil)
correctMAC[len(correctMAC)-1] ^= 0xFF
wrongSig := "sha256=" + hex.EncodeToString(correctMAC)
r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("X-Pangolin-Signature", wrongSig)
r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
r.Header.Set("X-Pangolin-Nonce", "test-nonce-ct")
h := newWebhookHandlerNoRedis(secret)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
// --------------------------------------------------------------------------
// Helper
// --------------------------------------------------------------------------
// assertErrorCode decodes the response body and checks the "code" field.
func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) {
t.Helper()
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Errorf("decode error body: %v", err)
return
}
if body.Code != wantCode {
t.Errorf("error code = %q, want %q", body.Code, wantCode)
}
}