Files
affiliate_dash/backend/internal/pkg/lewan/verify_webhook_test.go
T
yml2213 a47b61a98d refactor(sign): 抽取公共签名原语 signutil,三套签名串格式保持不变
商户侧 /api/client/v1、源头侧 /api/open/v1 与乐玩开放平台各有一套签名实现,
其中语义完全一致的部分重复了多份,任一处修复都容易漏改另外两处。

- 新增 internal/pkg/signutil,只收敛四类一致性原语:HMAC-SHA256→hex、
  body SHA256 摘要、时间戳偏差校验、常数时间比较
- 中间件与 lewan 改为调用该包,删除 middleware 里重复的 hmacSHA256Hex 与 abs64
- 刻意不统一「签名串构造」:三者拼接方式是对外契约且互不相同
  (商户:字典序 & + body 摘要;源头:字典序 & + body 原文;乐玩:换行 6 行),
  强行合并会直接改变握手结果
- 保留各处原有细节语义:商户侧十六进制签名大小写不敏感、乐玩签名原样比较、
  VerifyWebhook 在 allowedSkew<=0 时仍要求时间戳可解析

验证:三套方案既有端到端测试全部通过(含乐玩官方测试向量、大写签名、重放拒绝);
新增 signutil 已知向量与偏差/比较边界测试,以及 VerifyWebhook 边界语义测试。
2026-10-06 19:53:55 +08:00

108 lines
2.8 KiB
Go

package lewan
import (
"strconv"
"testing"
"time"
)
// 这些用例锁定 VerifyWebhook 在「共享原语重构」时被刻意保留的边界语义。
func TestVerifyWebhookTimestampContract(t *testing.T) {
const (
secret = "whsec_test"
eventID = "evt_1"
)
rawBody := []byte(`{"hello":"world"}`)
now := time.Now().Unix()
cases := []struct {
name string
timestamp string
allowedSkew time.Duration
want bool
}{
{
name: "allowedSkew 为 0 时仍要求时间戳可解析",
timestamp: "not-a-number",
allowedSkew: 0,
want: false,
},
{
name: "allowedSkew 为 0 时同一秒通过",
timestamp: strconv.FormatInt(now, 10),
allowedSkew: 0,
want: true,
},
{
name: "allowedSkew 为 0 时不校验时间窗口",
timestamp: strconv.FormatInt(now-86400, 10),
allowedSkew: 0,
want: true,
},
{
name: "指定窗口内通过",
timestamp: strconv.FormatInt(now-60, 10),
allowedSkew: 5 * time.Minute,
want: true,
},
{
name: "超出窗口被拒",
timestamp: strconv.FormatInt(now-600, 10),
allowedSkew: 5 * time.Minute,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sig := SignWebhook(secret, tc.timestamp, eventID, rawBody)
got := VerifyWebhook(secret, sig, tc.timestamp, eventID, rawBody, tc.allowedSkew)
if got != tc.want {
t.Fatalf("期望 %v,实际 %v", tc.want, got)
}
})
}
}
// 带不带 v1= 前缀都应被接受。
func TestVerifyWebhookAcceptsSignatureWithoutPrefix(t *testing.T) {
const (
secret = "whsec_test"
eventID = "evt_2"
)
rawBody := []byte(`{"a":1}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
full := SignWebhook(secret, timestamp, eventID, rawBody)
if !VerifyWebhook(secret, full, timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("带 v1= 前缀的签名应通过")
}
if !VerifyWebhook(secret, full[len("v1="):], timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("不带 v1= 前缀的签名也应通过")
}
}
// 任一必填字段为空都必须直接拒绝。
func TestVerifyWebhookRejectsEmptyInputs(t *testing.T) {
rawBody := []byte(`{}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
const (
secret = "whsec_test"
eventID = "evt_3"
)
sig := SignWebhook(secret, timestamp, eventID, rawBody)
if VerifyWebhook("", sig, timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("空 secret 必须被拒")
}
if VerifyWebhook(secret, "", timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("空签名必须被拒")
}
if VerifyWebhook(secret, sig, "", eventID, rawBody, 5*time.Minute) {
t.Fatal("空时间戳必须被拒")
}
if VerifyWebhook(secret, sig, timestamp, "", rawBody, 5*time.Minute) {
t.Fatal("空 eventID 必须被拒")
}
}