Files
commerce/thirdparty/square/webhook_signature_test.go
zooqueenandHanzo Dev ca7fd95c5c A dispute moves money only while Square withholds the funds, every dispute report is ordered by the dispute's version, and Square's dispute.state.updated is read as dispute.state.changed was
dispute.state.updated and dispute.state.changed both normalize to dispute.updated
and both are clawback events. A dispute report carries the dispute's version and
updated_at, and dispute.Report (embedded in the invoice and in the wallet payment's
record, models/paymentorg) keeps the latest: a report older by version, else by
updated_at, else by phase (inquiry, chargeback, decided), changes nothing, and WON
stays final.

An inquiry holds no funds: it takes nothing from a wallet, and on an invoice it is
recorded while the period stays paid. A chargeback (EVIDENCE_REQUIRED, PROCESSING),
or one lost or accepted, takes a wallet payment back once and unpays a period; a
chargeback won gives the wallet payment back and restores the period.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:42:55 -07:00

81 lines
2.6 KiB
Go

package square
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"testing"
)
// squareSign reproduces Square's documented webhook signing: base64 of
// HMAC-SHA256 over (notificationURL + rawBody) keyed by the signature key.
// See https://developer.squareup.com/docs/webhooks/step3validate
func squareSign(key, notificationURL string, body []byte) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(notificationURL))
mac.Write(body)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func TestValidateWebhook_SignsOverURLPlusBody(t *testing.T) {
const (
key = "test-signature-key"
url = "https://commerce.hanzo.ai/v1/billing/webhooks/square"
)
body := []byte(`{"event_id":"evt_1","type":"payment.updated","data":{"type":"payment","id":"pay_1","object":{"id":"pay_1","status":"COMPLETED"}}}`)
sp := NewProcessor(Config{
AccessToken: "tok",
LocationID: "loc",
WebhookSecret: key,
WebhookURL: url,
Environment: "production",
})
// Correct signature (URL + body) must validate.
good := squareSign(key, url, body)
evt, err := sp.ValidateWebhook(nil, body, good)
if err != nil {
t.Fatalf("valid signature rejected: %v", err)
}
if evt == nil || evt.ID != "evt_1" || evt.Type != "payment.updated" {
t.Fatalf("unexpected parsed event: %+v", evt)
}
// Body-only signature (the old, wrong algorithm) must NOT validate —
// this is the regression guard for the real-callback bug.
bodyOnly := squareSign(key, "", body)
if _, err := sp.ValidateWebhook(nil, body, bodyOnly); err == nil {
t.Fatal("body-only signature was accepted; URL prefix not enforced")
}
// Wrong URL must NOT validate.
wrongURL := squareSign(key, "https://evil.example/webhook", body)
if _, err := sp.ValidateWebhook(nil, body, wrongURL); err == nil {
t.Fatal("signature for wrong URL was accepted")
}
}
func TestValidateWebhook_MissingURLFailsClosed(t *testing.T) {
sp := NewProcessor(Config{
AccessToken: "tok",
LocationID: "loc",
WebhookSecret: "k",
// WebhookURL intentionally empty.
Environment: "production",
})
if _, err := sp.ValidateWebhook(nil, []byte(`{}`), "anything"); err == nil {
t.Fatal("expected failure when webhook URL is unconfigured")
}
}
// Square names a dispute's change of state dispute.state.updated, and once named it
// dispute.state.changed; both are the one normalized dispute.updated.
func TestMapSquareEventType_DisputeStateBothNames(t *testing.T) {
for _, sq := range []string{"dispute.state.updated", "dispute.state.changed"} {
if got := mapSquareEventType(sq); got != "dispute.updated" {
t.Fatalf("mapSquareEventType(%q) = %q, want dispute.updated", sq, got)
}
}
}