787151245e
解决与 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>
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"
|
||
|
||
"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)
|
||
}
|
||
}
|