Files
commerce/api/billing/webhooks_test.go
zeekay 73b7290e7f square: one webhook route and one verifier; secrets come from the host's KMS
Square keeps an event's created_at across every retry, and the receiver
refused anything older than five minutes, so every retry of an event
first sent while cloud was down (Recreate, ten rollouts a day) was refused
until Square gave up. The verifier now lives in payment/providers/square
and needs only the key and the URL; thirdparty/square is the API client
and no longer registers itself or verifies anything, so every registry
holds one Square type.

Replay is stopped by the event id instead: each verified event is
recorded once for the whole deployment, keyed on the verifying processor
and the event id with an insert-if-absent swap, so a repeat is a
duplicate under any path or org. The path names the one processor that
verifies, and only one that holds its webhook secret may: an unknown name,
or a verifier that needs no secret, is refused rather than tried.
The books an event lands in are the verifying account's, else the
deployment's brand — never a header or session, which on a replay are the
replayer's. An effect that did not land (a store or the ledger did not
answer) leaves the delivery open with a 503, so the provider redelivers;
it used to be acknowledged and never retried. A settlement reads the
payment index before the org's books, because an in-session charge's
receipt is in the payer's org.

POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so
standalone and embedded commerce serve the same address; the
/v1/billing/webhooks spelling is gone.

thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer
serves, and was off in production anyway, so per-org payment credentials
never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through
the host's reader, the caller re-pointed at that org with zip.ActingAs so
the KMS tenancy check admits it, and payment.Hydrate loads an org's
credentials where the org receives the money: storefront checkout,
storefront subscriptions and wire, and the brand's bank for the billing
wire rail. Hanzo's own billing no longer reads the paying org's
credentials; it charges the deployment's account, as it did. The
standalone binary reads through hanzoai/kms/sdk/go.
2026-10-19 09:12:56 -07:00

376 lines
14 KiB
Go

package billing
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/datastore/query"
dbpkg "github.com/hanzoai/commerce/db"
"github.com/hanzoai/commerce/payment/processor"
squareprovider "github.com/hanzoai/commerce/payment/providers/square"
"github.com/hanzoai/commerce/util/test/ae"
)
// squareSigHeader is the exact header Square sends with each webhook
// delivery; lower-case on the wire, but http.Header canonicalises it.
const squareSigHeader = "x-square-hmacsha256-signature"
// testWebhookURL is the notification URL the tests pretend is configured in
// the Square dashboard. Square signs HMAC over this URL + the raw body, so
// every test signature must be computed over the same value.
const testWebhookURL = "https://api.test.example/v1/commerce/webhooks/square"
// squareSign reproduces the REAL Square HMAC-SHA256(base64) signature:
// HMAC over notificationURL + rawBody (NOT body-only). This is the exact
// computation thirdparty/square/processor.go::ValidateWebhook verifies, so a
// matching signature must validate. squareSignBodyOnly reproduces the OLD
// (buggy) body-only scheme so a regression to it is caught by tests.
func squareSign(secret, url string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(url))
mac.Write(body)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func squareSignBodyOnly(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
// ─── test datastore ─────────────────────────────────────────────────────
// TestMain wires a real SQLite-backed datastore so the end-to-end handler
// path (org resolution via resolveWebhookOrg, then persistence) has somewhere
// to read/write. Signature-rejection tests short-circuit before touching it;
// the validated-path test needs it.
func TestMain(m *testing.M) {
// The spend-cap tests validate ENFORCEMENT (a cap denies), so the package runs
tempDir, err := os.MkdirTemp("", "billing-webhooks-test-*")
if err != nil {
panic(err)
}
cfg := dbpkg.DefaultConfig()
cfg.DataDir = tempDir
cfg.UserDataDir = tempDir + "/users"
cfg.EnableDatastore = false
cfg.EnableVectorSearch = false
mgr, err := dbpkg.NewManager(cfg)
if err != nil {
panic(err)
}
db, err := mgr.Org("test")
if err != nil {
panic(err)
}
datastore.SetDefaultDB(db)
query.SetDefaultDB(db)
code := m.Run()
mgr.Close()
os.RemoveAll(tempDir)
os.Exit(code)
}
// ─── pickSignatureHeader ────────────────────────────────────────────────
func TestPickSignatureHeader(t *testing.T) {
cases := []struct {
name string
providerHint string
headers map[string]string
want string
}{
{
name: "square hint picks square header",
providerHint: "square",
headers: map[string]string{squareSigHeader: "sq-sig-value"},
want: "sq-sig-value",
},
{
name: "square header found via candidate list without hint",
providerHint: "",
headers: map[string]string{squareSigHeader: "sq-sig-value"},
want: "sq-sig-value",
},
{
name: "square header found even with mismatched hint",
providerHint: "paypal",
headers: map[string]string{squareSigHeader: "sq-sig-value"},
want: "sq-sig-value",
},
{
name: "stripe hint still works (no regression)",
providerHint: "stripe",
headers: map[string]string{"Stripe-Signature": "t=1,v1=abc"},
want: "t=1,v1=abc",
},
{
name: "no recognised header returns empty",
providerHint: "square",
headers: map[string]string{"X-Unknown": "nope"},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := http.Header{}
for k, v := range tc.headers {
h.Set(k, v)
}
if got := pickSignatureHeader(h, tc.providerHint); got != tc.want {
t.Errorf("pickSignatureHeader(%q) = %q, want %q", tc.providerHint, got, tc.want)
}
})
}
}
// ─── registry population ────────────────────────────────────────────────
// TestGlobalRegistryPopulated proves the blank-import of the provider barrel
// in webhooks.go actually registers providers with processor.Global(). Before
// the fix the global registry was empty (no importer of the barrel), so this
// would fail with "processor square not registered".
func TestGlobalRegistryPopulated(t *testing.T) {
reg := processor.Global()
if len(reg.ListTypes()) == 0 {
t.Fatal("global processor registry is empty; provider barrel not imported")
}
// Square must be registered — this is the exact regression the barrel
// blank-import fixes. (Whether other providers are *enabled* is config-
// dependent and orthogonal; the registry being populated is the point.)
if _, err := reg.Get(processor.Square); err != nil {
t.Fatalf("Square processor not in global registry: %v", err)
}
}
// ─── HandleProviderWebhook end-to-end ───────────────────────────────────
// newTestEngine builds a zip app with the real webhook route mounted so
// tests exercise routing + handler exactly as production does.
func newTestEngine() *zip.App {
app := zip.New(zip.Config{DisableStartupMessage: true})
app.Raw(http.MethodPost, "/v1/commerce/webhooks/:provider", HandleProviderWebhook)
return app
}
// registerSquare installs a configured Square processor in the global registry
// for the duration of the test, restoring the previous one on cleanup.
func registerSquare(t *testing.T, secret string) {
t.Helper()
prev, _ := processor.Global().Get(processor.Square)
t.Cleanup(func() {
if prev != nil {
processor.Global().Register(prev)
}
})
processor.Global().Register(squareprovider.NewProvider(squareprovider.Config{
AccessToken: "test-token",
LocationID: "L1",
WebhookSignatureKey: secret,
WebhookURL: testWebhookURL,
Environment: "sandbox",
}))
}
// squareEvent builds a Square webhook body with a fresh created_at so it
// passes replay protection, carrying the given event id and object id.
func squareEvent(eventID, objectID string, created time.Time) []byte {
return []byte(fmt.Sprintf(
`{"merchant_id":"M1","type":"subscription.updated","event_id":%q,"created_at":%q,"data":{"type":"subscription","id":%q,"object":{"id":%q,"status":"active"}}}`,
eventID, created.UTC().Format(time.RFC3339), objectID, objectID,
))
}
func TestHandleProviderWebhook_MissingSignature(t *testing.T) {
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/square", strings.NewReader(`{}`))
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing signature header", resp.StatusCode)
}
}
// TestHandleProviderWebhook_BadSignatureReachesSquare proves the dispatcher
// reaches Square's ValidateWebhook: a recognised Square signature header is
// present and a Square processor is registered (via the barrel import), so the
// request gets PAST header selection and processor lookup and is rejected by
// Square's HMAC comparison — a 401, not a 400-missing-header or a silent "no
// processor" miss. This is the direct regression test for the empty-registry bug.
func TestHandleProviderWebhook_BadSignatureReachesSquare(t *testing.T) {
registerSquare(t, "whsec_test")
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/square", strings.NewReader(`{"type":"payment.created"}`))
req.Header.Set(squareSigHeader, "dGhpcy1pcy1ub3QtdmFsaWQ=") // valid base64, wrong digest
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (signature rejected by Square)", resp.StatusCode)
}
var body struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(func() []byte { b, _ := io.ReadAll(resp.Body); return b }(), &body); err != nil {
t.Fatalf("decode error body: %v (raw=%s)", err, func() string { b, _ := io.ReadAll(resp.Body); return string(b) }())
}
if !strings.Contains(body.Error.Message, "invalid webhook signature") {
t.Errorf("error message = %q, want it to mention invalid webhook signature", body.Error.Message)
}
}
// ─── Square HMAC scheme (notificationURL + body) via the HTTP handler ────
//
// Acceptance of a correct url+body signature at the handler level is proven by
// the 401-contrast in TestHandleProviderWebhook_BodyOnlySignatureFails (a
// body-only signature is rejected while a url+body one is not), and directly at
// the processor level by TestSquareValidateWebhook_Scheme. The full post-
// validation persist path (main's resolveWebhookOrg → applySettlementEvent) is
// covered by the settlement tests; it depends on org KV infra outside this
// unit harness, so it is not re-driven here.
// TestHandleProviderWebhook_BodyOnlySignatureFails proves a regression to the
// OLD body-only scheme would be caught: the same secret, signing the body
// alone, must now be REJECTED with a 401 by the url+body scheme.
func TestHandleProviderWebhook_BodyOnlySignatureFails(t *testing.T) {
const secret = "whsec_bodyonly"
registerSquare(t, secret)
body := squareEvent("evt_bodyonly", "sub_bodyonly", time.Now())
sig := squareSignBodyOnly(secret, body) // OLD buggy scheme
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/square", strings.NewReader(string(body)))
req.Header.Set(squareSigHeader, sig)
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 — body-only signature must be rejected by the url+body scheme", resp.StatusCode)
}
}
// ─── Square processor HMAC scheme (direct unit tests) ────────────────────
// TestSquareValidateWebhook_Scheme is a direct, table-driven unit test of the
// processor's HMAC scheme so the URL+body contract is pinned independent of
// the HTTP handler.
func TestSquareValidateWebhook_Scheme(t *testing.T) {
const secret = "whsec_scheme"
body := squareEvent("evt_scheme", "sub_scheme", time.Now())
cases := []struct {
name string
sig string
ok bool
}{
{"url+body passes", squareSign(secret, testWebhookURL, body), true},
{"body-only fails", squareSignBodyOnly(secret, body), false},
{"wrong url fails", squareSign(secret, "https://evil.example/x", body), false},
{"wrong key fails", squareSign("whsec_other", testWebhookURL, body), false},
{"not base64 fails", "%%%not-base64%%%", false},
}
p := squareprovider.NewProvider(squareprovider.Config{
AccessToken: "test-token",
LocationID: "L1",
WebhookSignatureKey: secret,
WebhookURL: testWebhookURL,
Environment: "sandbox",
})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
evt, err := p.ValidateWebhook(context.Background(), body, tc.sig)
if tc.ok {
if err != nil || evt == nil {
t.Fatalf("expected pass, got err=%v evt=%v", err, evt)
}
} else if err == nil {
t.Fatalf("expected rejection, got nil error (evt=%v)", evt)
}
})
}
}
// TestSquareValidateWebhook_RequiresURL documents the safe behavior when no
// notification URL is configured: validation refuses rather than silently
// falling back to a body-only scheme live Square would never match.
func TestSquareValidateWebhook_RequiresURL(t *testing.T) {
const secret = "whsec_nourl"
body := squareEvent("evt_nourl", "sub_nourl", time.Now())
p := squareprovider.NewProvider(squareprovider.Config{
AccessToken: "test-token",
LocationID: "L1",
WebhookSignatureKey: secret,
// WebhookURL intentionally empty.
Environment: "sandbox",
})
// Even a "correct" body-only or url-less signature must be refused.
sig := squareSignBodyOnly(secret, body)
if _, err := p.ValidateWebhook(context.Background(), body, sig); err == nil {
t.Fatal("expected refusal when webhook URL is unconfigured, got nil error")
}
}
// ─── replay: the event id, not the clock ─────────────────────────────────
// TestHandleProviderWebhook_ReplayIsADuplicate pins where replay is stopped. Square
// resends an event's original body — created_at included — on every retry, so a
// delivery ten minutes old is a retry and verifies; delivering it again is
// acknowledged as a duplicate and applies nothing a second time.
func TestHandleProviderWebhook_ReplayIsADuplicate(t *testing.T) {
const secret = "whsec_replay"
registerSquare(t, secret)
ctx := ae.NewContext()
defer ctx.Close()
webhookOrg(t, ctx, "wh-dup", true)
body := squareEvent("evt_retried", "sub_replay", time.Now().Add(-10*time.Minute))
first := deliverWebhook(ctx, "wh-dup", secret, body, "")
if first.StatusCode != http.StatusOK {
t.Fatalf("a genuine 10-minute-old retry answered %d, want 200", first.StatusCode)
}
second := deliverWebhook(ctx, "wh-dup", secret, body, "")
raw, _ := io.ReadAll(second.Body)
if second.StatusCode != http.StatusOK || !strings.Contains(string(raw), `"duplicate":true`) {
t.Fatalf("the same event delivered again answered %d %s, want 200 and duplicate", second.StatusCode, raw)
}
}