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>
This commit is contained in:
wangjia
2026-06-13 03:03:11 +08:00
18 changed files with 2851 additions and 20 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"
"github.com/wangjia/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)
}
}