Files
pangolin/server/internal/codes/webhook_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

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"
"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)
}
}