功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性

在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-11 15:48:03 +08:00
co-authored by Cursor
parent a314f3f51b
commit f7f48d0feb
22 changed files with 6489 additions and 189 deletions
+340
View File
@@ -0,0 +1,340 @@
package lewan
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type Config struct {
BaseURL string
AccessKey string
SecretKey string
ApplicationID string
WebhookSecret string
}
type Client struct {
cfg Config
httpClient *http.Client
}
func NewClient(cfg Config, httpClient *http.Client) *Client {
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if cfg.BaseURL == "" {
cfg.BaseURL = "https://shop.cloudtentacles.com"
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{
cfg: cfg,
httpClient: httpClient,
}
}
func (c *Client) SetHTTPClient(client *http.Client) {
if client != nil {
c.httpClient = client
}
}
// 统一包装响应
type ResponseEnvelope struct {
Data json.RawMessage `json:"data"`
RequestId string `json:"requestId"`
StatusCode int `json:"statusCode,omitempty"`
Error string `json:"error,omitempty"`
Message interface{} `json:"message,omitempty"` // string or []string
Code string `json:"code,omitempty"`
}
// Product 代表乐玩平台商品
type Product struct {
ID string `json:"id"`
CategoryId string `json:"categoryId"`
CategoryName string `json:"categoryName"`
Name string `json:"name"`
Summary string `json:"summary"`
ThumbnailUrl string `json:"thumbnailUrl"`
Stock int64 `json:"stock"`
RoleDeliveryLimit *int `json:"roleDeliveryLimit"`
DeliveryType string `json:"deliveryType"` // LINK_BINDING, EMAIL_BINDING
EffectiveUnitPrice string `json:"effectiveUnitPrice"`
AccessMode string `json:"accessMode"` // PURCHASE, VIEW
UpdatedAt string `json:"updatedAt"`
ProductVersion string `json:"productVersion"`
}
type ProductListResult struct {
Items []Product `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// OrderItem 下单商品项
type OrderItem struct {
ProductID string `json:"productId"`
Quantity int64 `json:"quantity"`
}
// CreateOrderReq 下单请求
type CreateOrderReq struct {
CallerOrderID string `json:"callerOrderId"`
RoleID string `json:"roleId"`
Items []OrderItem `json:"items"`
}
// BindingAction 绑定动作快照
type BindingAction struct {
BindingRequired bool `json:"bindingRequired"`
BindingType string `json:"bindingType"`
ExpiresAt *string `json:"expiresAt"`
BindingDeadline *string `json:"bindingDeadline"`
BindingUrl string `json:"bindingUrl"`
BindingUrlIssuedAt *string `json:"bindingUrlIssuedAt"`
BindingUrlExpiresAt *string `json:"bindingUrlExpiresAt"`
BindingUrlExpired bool `json:"bindingUrlExpired"`
BindingActionEndpoint *string `json:"bindingActionEndpoint"`
EmailCodeSent bool `json:"emailCodeSent"`
AllocationInProgress bool `json:"allocationInProgress"`
BindingActionInProgress bool `json:"bindingActionInProgress"`
}
// LewanOrder 乐玩订单
type LewanOrder struct {
ID string `json:"id"`
OrderNo string `json:"orderNo"`
CallerOrderID string `json:"callerOrderId"`
OrderVersion int `json:"orderVersion"`
RoleID string `json:"roleId"`
CategoryId string `json:"categoryId"`
DeliveryType string `json:"deliveryType"`
OrderedAmount string `json:"orderedAmount"`
SettledAmount string `json:"settledAmount"`
ReleasedAmount string `json:"releasedAmount"`
HeldAmount string `json:"heldAmount"`
FeeAmount string `json:"feeAmount"`
Quantity int64 `json:"quantity"`
SucceededQuantity int64 `json:"succeededQuantity"`
FailedQuantity int64 `json:"failedQuantity"`
ReleasedQuantity int64 `json:"releasedQuantity"`
PendingQuantity int64 `json:"pendingQuantity"`
FailureCodes []string `json:"failureCodes"`
Status string `json:"status"` // WAITING_BINDING, QUEUED, PROCESSING, RECONCILING, SUCCEEDED, PARTIAL_SUCCESS, FAILED, CANCELLED
BindingDeadline *string `json:"bindingDeadline"`
BindingAction *BindingAction `json:"bindingAction,omitempty"`
}
// CreateOrderResult 下单响应
type CreateOrderResult struct {
Order *LewanOrder `json:"order"`
BindingAction *BindingAction `json:"bindingAction"`
IdempotentReplay bool `json:"idempotentReplay"`
}
// BindingResult 绑定刷新响应
type BindingInfo struct {
Status string `json:"status"`
Verified bool `json:"verified"`
Matched bool `json:"matched"`
RoleID string `json:"roleId"`
RoleName string `json:"roleName"`
ServerName string `json:"serverName"`
}
type RefreshBindingResult struct {
Order *LewanOrder `json:"order"`
Binding *BindingInfo `json:"binding"`
}
type WalletBalance struct {
AvailableBalance string `json:"availableBalance"`
FrozenBalance string `json:"frozenBalance"`
TotalBalance string `json:"totalBalance"`
}
// ListProducts 查询当前可见商品列表
func (c *Client) ListProducts(ctx context.Context, page, pageSize int, keyword string) (*ProductListResult, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
query := fmt.Sprintf("page=%d&pageSize=%d", page, pageSize)
if keyword != "" {
query += "&keyword=" + RFC3986Escape(keyword)
}
var out ProductListResult
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products", query, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetProduct 查询单个商品详情
func (c *Client) GetProduct(ctx context.Context, id string) (*Product, error) {
var out Product
if err := c.do(ctx, http.MethodGet, "/openapi/v1/products/"+id, "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// CreateOrder 幂等创建聚合订单
func (c *Client) CreateOrder(ctx context.Context, req CreateOrderReq) (*CreateOrderResult, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
var out CreateOrderResult
if err := c.do(ctx, http.MethodPost, "/openapi/v1/orders", "", body, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetOrder 按订单 ID 查单
func (c *Client) GetOrder(ctx context.Context, id string) (*LewanOrder, error) {
var out LewanOrder
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/"+id, "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetOrderByCallerOrder 按调用方订单号精确查单(用于超时补偿)
func (c *Client) GetOrderByCallerOrder(ctx context.Context, callerOrderID string) (*LewanOrder, error) {
query := "callerOrderId=" + RFC3986Escape(callerOrderID)
var out LewanOrder
if err := c.do(ctx, http.MethodGet, "/openapi/v1/orders/by-caller-order-id", query, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// RestoreBindingAction 重新进入时显式获取或恢复原订单绑定动作
func (c *Client) RestoreBindingAction(ctx context.Context, orderID string) (*CreateOrderResult, error) {
var out CreateOrderResult
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/action", orderID), "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// RefreshBinding LINK 用户绑定后刷新角色信息;匹配成功即进入发货
func (c *Client) RefreshBinding(ctx context.Context, orderID string) (*RefreshBindingResult, error) {
var out RefreshBindingResult
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/binding/refresh", orderID), "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// CancelOrder 取消整单(仅在未越过渠道提交边界时有效)
func (c *Client) CancelOrder(ctx context.Context, orderID string) (*LewanOrder, error) {
var out LewanOrder
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/openapi/v1/orders/%s/cancel", orderID), "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetWallet 查询钱包余额
func (c *Client) GetWallet(ctx context.Context) (*WalletBalance, error) {
var out WalletBalance
if err := c.do(ctx, http.MethodGet, "/openapi/v1/wallets", "", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
func (c *Client) do(ctx context.Context, method, path, query string, body []byte, out interface{}) error {
if c.cfg.AccessKey == "" || c.cfg.SecretKey == "" {
return errors.New("乐玩开放平台 AccessKey 或 SecretKey 未配置")
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := GenerateNonce()
bodySHA := SHA256Hex(body)
signingStr := BuildSigningString(method, path, query, bodySHA, ts, nonce)
signature := SignRequest(c.cfg.SecretKey, signingStr)
reqURL := c.cfg.BaseURL + path
if query != "" {
reqURL += "?" + query
}
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return fmt.Errorf("构建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set(HeaderAccessKey, c.cfg.AccessKey)
req.Header.Set(HeaderTimestamp, ts)
req.Header.Set(HeaderNonce, nonce)
req.Header.Set(HeaderSignature, signature)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("请求乐玩平台网络异常: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<21)) // limit 2MB
if err != nil {
return fmt.Errorf("读取乐玩响应失败: %w", err)
}
var envelope ResponseEnvelope
if err := json.Unmarshal(respBytes, &envelope); err != nil {
return fmt.Errorf("解析乐玩响应失败 (HTTP %d): %s", resp.StatusCode, string(respBytes))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errMsg := ""
switch v := envelope.Message.(type) {
case string:
errMsg = v
case []interface{}:
var parts []string
for _, item := range v {
parts = append(parts, fmt.Sprint(item))
}
errMsg = strings.Join(parts, "; ")
}
if errMsg == "" {
errMsg = envelope.Error
}
if errMsg == "" {
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
if envelope.Code != "" {
return fmt.Errorf("[%s] %s", envelope.Code, errMsg)
}
return errors.New(errMsg)
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("解析乐玩业务数据失败: %w", err)
}
}
return nil
}
+198
View File
@@ -0,0 +1,198 @@
package lewan
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
const (
EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
HeaderAccessKey = "X-OpenAPI-Access-Key"
HeaderTimestamp = "X-OpenAPI-Timestamp"
HeaderNonce = "X-OpenAPI-Nonce"
HeaderSignature = "X-OpenAPI-Signature"
HeaderRequestID = "X-Request-Id"
HeaderWebhookAppID = "X-OpenAPI-Application-Id"
HeaderWebhookEventID = "X-OpenAPI-Event-Id"
HeaderWebhookSchemaVer = "X-OpenAPI-Schema-Version"
HeaderWebhookTimestamp = "X-OpenAPI-Timestamp"
HeaderWebhookSignature = "X-OpenAPI-Signature"
)
// RFC3986Escape encodes a string according to RFC 3986.
// Space is encoded as %20 (not +).
func RFC3986Escape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if isRFC3986Unreserved(c) {
b.WriteByte(c)
} else {
fmt.Fprintf(&b, "%%%02X", c)
}
}
return b.String()
}
func isRFC3986Unreserved(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9') ||
b == '-' || b == '_' || b == '.' || b == '~'
}
type queryPair struct {
encodedKey string
encodedVal string
}
// NormalizeQuery parses a raw query string and normalizes it according to Lewan OpenAPI V1 rules:
// RFC 3986 encoded key and value, sorted by encoded key then encoded value, joined by '&'.
func NormalizeQuery(rawQuery string) string {
if strings.TrimSpace(rawQuery) == "" {
return ""
}
parts := strings.Split(rawQuery, "&")
var pairs []queryPair
for _, part := range parts {
if part == "" {
continue
}
var rawKey, rawVal string
idx := strings.IndexByte(part, '=')
if idx >= 0 {
rawKey = part[:idx]
rawVal = part[idx+1:]
} else {
rawKey = part
rawVal = ""
}
// Unescape first if percent-encoded, to canonicalize
decKey, err := url.QueryUnescape(strings.ReplaceAll(rawKey, "+", "%2B"))
if err != nil {
decKey = rawKey
}
decVal, err := url.QueryUnescape(strings.ReplaceAll(rawVal, "+", "%2B"))
if err != nil {
decVal = rawVal
}
pairs = append(pairs, queryPair{
encodedKey: RFC3986Escape(decKey),
encodedVal: RFC3986Escape(decVal),
})
}
sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].encodedKey != pairs[j].encodedKey {
return pairs[i].encodedKey < pairs[j].encodedKey
}
return pairs[i].encodedVal < pairs[j].encodedVal
})
var sb strings.Builder
for i, p := range pairs {
if i > 0 {
sb.WriteByte('&')
}
sb.WriteString(p.encodedKey)
sb.WriteByte('=')
sb.WriteString(p.encodedVal)
}
return sb.String()
}
// SHA256Hex returns the hex-encoded SHA-256 of the byte slice.
func SHA256Hex(body []byte) string {
if len(body) == 0 {
return EmptyBodySHA256
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
// BuildSigningString constructs the 6-line string to be signed:
// 1. HTTP_METHOD (UPPERCASE)
// 2. PATH
// 3. RFC3986_QUERY
// 4. SHA256_HEX_OF_RAW_BODY
// 5. UNIX_TIMESTAMP_SECONDS
// 6. NONCE
func BuildSigningString(method, path, query, bodySHA256, timestamp, nonce string) string {
method = strings.ToUpper(strings.TrimSpace(method))
path = strings.TrimSpace(path)
normQuery := NormalizeQuery(query)
if bodySHA256 == "" {
bodySHA256 = EmptyBodySHA256
}
timestamp = strings.TrimSpace(timestamp)
nonce = strings.TrimSpace(nonce)
return strings.Join([]string{
method,
path,
normQuery,
bodySHA256,
timestamp,
nonce,
}, "\n")
}
// SignRequest computes the hex HMAC-SHA256 signature for a request.
func SignRequest(secretKey, signingString string) string {
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(signingString))
return hex.EncodeToString(mac.Sum(nil))
}
// GenerateNonce creates a random hex nonce of given byte length (e.g. 16 bytes = 32 hex chars).
func GenerateNonce() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return "n" + hex.EncodeToString(b)[1:] // ensure leading char is letter/number
}
// SignWebhook computes the v1= signature for an incoming/outgoing webhook.
func SignWebhook(secret, timestamp, eventID string, rawBody []byte) string {
prefix := timestamp + "." + eventID + "."
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(prefix))
mac.Write(rawBody)
return "v1=" + hex.EncodeToString(mac.Sum(nil))
}
// VerifyWebhook validates a webhook signature with constant-time comparison and timestamp check.
func VerifyWebhook(secret, signature, timestamp, eventID string, rawBody []byte, allowedSkew time.Duration) bool {
if secret == "" || signature == "" || timestamp == "" || eventID == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if allowedSkew > 0 {
diff := time.Now().Unix() - ts
if diff < 0 {
diff = -diff
}
if time.Duration(diff)*time.Second > allowedSkew {
return false
}
}
expected := SignWebhook(secret, timestamp, eventID, rawBody)
actual := signature
if !strings.HasPrefix(actual, "v1=") {
actual = "v1=" + actual
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
+124
View File
@@ -0,0 +1,124 @@
package lewan
import (
"testing"
"time"
)
func TestOfficialVectors(t *testing.T) {
// 固定密钥与时间戳
accessKey := "ak_test_fixed_20260905"
secretKey := "sk_test_fixed_20260905"
timestamp := "1788566400"
_ = accessKey
// Vector 1: GET 查询
t.Run("Vector1_GET_Query", func(t *testing.T) {
method := "GET"
path := "/openapi/v1/products"
rawQuery := "pageSize=20&page=1"
nonce := "nonce_get_20260905_fixed"
bodySHA256 := EmptyBodySHA256
expectedSig := "54ea478585e61230be670d664745c81ffb4907b87c8592bf082c9cc894232adf"
signingStr := BuildSigningString(method, path, rawQuery, bodySHA256, timestamp, nonce)
expectedSigningStr := "GET\n/openapi/v1/products\npage=1&pageSize=20\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n1788566400\nnonce_get_20260905_fixed"
if signingStr != expectedSigningStr {
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
}
sig := SignRequest(secretKey, signingStr)
if sig != expectedSig {
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
}
})
// Vector 2: POST JSON
t.Run("Vector2_POST_JSON", func(t *testing.T) {
method := "POST"
path := "/openapi/v1/orders"
rawQuery := ""
body := []byte(`{"callerOrderId":"DOC-20260905-001","roleId":"1148711973","items":[{"productId":"00000000-0000-4000-8000-000000000101","quantity":2}]}`)
nonce := "nonce_post_20260905_fixed"
expectedBodySHA := "146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032"
expectedSig := "1e1d3df46b885e76b1e992585215e488c29315e7207e62df4aa26b0124d04968"
bodySHA := SHA256Hex(body)
if bodySHA != expectedBodySHA {
t.Fatalf("body SHA256 mismatch:\ngot: %s\nwant: %s", bodySHA, expectedBodySHA)
}
signingStr := BuildSigningString(method, path, rawQuery, bodySHA, timestamp, nonce)
expectedSigningStr := "POST\n/openapi/v1/orders\n\n146e687459b70c7fdf2781f72593306a8041aa0d5212207459dc71cd76e59032\n1788566400\nnonce_post_20260905_fixed"
if signingStr != expectedSigningStr {
t.Fatalf("signing string mismatch:\ngot:\n%s\nwant:\n%s", signingStr, expectedSigningStr)
}
sig := SignRequest(secretKey, signingStr)
if sig != expectedSig {
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
}
})
// Vector 3: 重复查询参数
t.Run("Vector3_Duplicate_Query", func(t *testing.T) {
method := "GET"
path := "/openapi/v1/orders"
rawQuery := "status=SUCCEEDED&keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED"
nonce := "nonce_repeat_20260905_fixed"
expectedSig := "c1c2be18372e3ac89e8042f3477fff8287126e31777a18a593c0cce7028626d8"
normQuery := NormalizeQuery(rawQuery)
expectedNormQuery := "keyword=%E4%BA%91%20%E8%A7%A6%E8%A7%92&status=FAILED&status=SUCCEEDED"
if normQuery != expectedNormQuery {
t.Fatalf("normalized query mismatch:\ngot: %s\nwant: %s", normQuery, expectedNormQuery)
}
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
sig := SignRequest(secretKey, signingStr)
if sig != expectedSig {
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
}
})
// Vector 4: POST 空请求体
t.Run("Vector4_POST_Empty_Body", func(t *testing.T) {
method := "POST"
path := "/openapi/v1/orders/00000000-0000-4000-8000-000000000201/account/release"
rawQuery := ""
nonce := "nonce_empty_20260905_fixed"
expectedSig := "167f6d7c116fc7e51b0d2418e65a0705f08076940025f2c013adbd8f27648908"
signingStr := BuildSigningString(method, path, rawQuery, EmptyBodySHA256, timestamp, nonce)
sig := SignRequest(secretKey, signingStr)
if sig != expectedSig {
t.Fatalf("signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
}
})
// Vector 5: Webhook 固定向量
t.Run("Vector5_Webhook", func(t *testing.T) {
webhookSecret := "whsec_test_fixed_20260905"
eventID := "00000000-0000-4000-8000-000000000302"
rawBody := []byte(`{"applicationId":"00000000-0000-4000-8000-000000000301","data":{"callerOrderId":"DOC-20260905-001","failedQuantity":1,"failureCodes":["SUPPLIER_REJECTED"],"orderId":"00000000-0000-4000-8000-000000000303","pendingQuantity":0,"releasedQuantity":0,"status":"PARTIAL_SUCCESS","succeededQuantity":1},"eventId":"00000000-0000-4000-8000-000000000302","occurredAt":"2026-09-05T00:00:00.000Z","orderVersion":7,"schemaVersion":"1.0","type":"ORDER_COMPLETED"}`)
expectedSig := "v1=ee3968ef467a4f9c19c0a33e7af1bb3118fab8d02c5ebf9cbd8001cc34ebe6be"
sig := SignWebhook(webhookSecret, timestamp, eventID, rawBody)
if sig != expectedSig {
t.Fatalf("webhook signature mismatch:\ngot: %s\nwant: %s", sig, expectedSig)
}
// 测试验证函数(不限时限)
if !VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 0) {
t.Fatalf("VerifyWebhook should succeed")
}
// 测试错误 secret
if VerifyWebhook("wrong_secret", sig, timestamp, eventID, rawBody, 0) {
t.Fatalf("VerifyWebhook should fail with wrong secret")
}
// 测试时间窗口超限
if VerifyWebhook(webhookSecret, sig, timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatalf("VerifyWebhook should fail for expired timestamp")
}
})
}