Files
pangolin/server/internal/codes/webhook_test.go
T
wangjia afcd7b325c 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>
2026-06-13 02:16:16 +08:00

199 lines
6.9 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.
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)
}
}