afcd7b325c
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>
199 lines
6.9 KiB
Go
199 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|