Files
commerce/api/billing/mpc_webhook_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

160 lines
5.7 KiB
Go

package billing
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/commerce/payment/processor"
"github.com/hanzoai/commerce/thirdparty/mpc"
)
// mpcSigHeader is the header the MPC service sends its digest in.
const mpcSigHeader = "X-Webhook-Signature"
// mpcSign reproduces the MPC service's signing: hex of HMAC-SHA256 over the raw
// body, keyed by the webhook secret (lux/mpc pkg/api/webhook_sender.go).
func mpcSign(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// mpcSettlementEvent is a delivery that mints credit if it validates: a
// completed payment naming beneficiary and amount ($1,000.00).
func mpcSettlementEvent(eventID string) []byte {
return []byte(`{"id":"` + eventID + `","type":"payment.completed","timestamp":1,` +
`"data":{"payment":{"id":"pay_` + eventID + `","status":"COMPLETED",` +
`"reference_id":"victim-user","amount_money":{"amount":100000,"currency":"usd"}}}}`)
}
// registerMPC installs a fully configured MPC processor — the prod shape, with
// MPC_ENDPOINT and a webhook secret provisioned — restoring the previous one on
// cleanup.
func registerMPC(t *testing.T, secret string) {
t.Helper()
prev, _ := processor.Global().Get(processor.MPC)
t.Cleanup(func() {
if prev != nil {
processor.Global().Register(prev)
}
})
processor.Global().Register(mpc.NewProcessor(mpc.Config{
KMSEndpoint: "https://kms.hanzo.ai",
MPCEndpoint: "https://mpc.hanzo.ai",
APIKey: "api-key",
WebhookSecret: secret,
}))
}
// TestMPCWebhook_ForgedSignatureRejected is the end-to-end regression test for
// the unauthenticated credit-mint bug. The mpc rail is registered in the global
// registry by the provider barrel, and /v1/commerce/webhooks/:provider is
// deliberately unauthenticated — the signature is the only trust anchor. Before
// the fix this exact request returned 200 and reached the mint with an
// attacker-chosen tenant, beneficiary and amount. It must now be a 401.
func TestMPCWebhook_ForgedSignatureRejected(t *testing.T) {
registerMPC(t, "whsec_mpc")
forged := []string{
"totally-forged-not-an-hmac",
"x",
"deadbeef",
strings.Repeat("a", 64), // correct length, wrong digest
}
for _, sig := range forged {
t.Run(sig, func(t *testing.T) {
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/mpc",
strings.NewReader(string(mpcSettlementEvent("evt_forged"))))
req.Header.Set(mpcSigHeader, sig)
// Attacker also chooses the tenant.
req.Header.Set("X-Org-Id", "victim-org")
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 — a forged signature must not reach the mint", resp.StatusCode)
}
})
}
}
// TestMPCWebhook_MissingSignatureRejected proves an unsigned delivery never gets
// past header selection.
func TestMPCWebhook_MissingSignatureRejected(t *testing.T) {
registerMPC(t, "whsec_mpc")
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/mpc",
strings.NewReader(string(mpcSettlementEvent("evt_nosig"))))
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 a delivery with no signature header", resp.StatusCode)
}
}
// TestMPCWebhook_UnsetSecretRejects proves the fail-closed property at the HTTP
// boundary: with no webhook secret provisioned the rail refuses every delivery
// rather than accepting all of them, which is what the old code did.
func TestMPCWebhook_UnsetSecretRejects(t *testing.T) {
registerMPC(t, "") // secret deliberately unset
body := mpcSettlementEvent("evt_nosecret")
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/mpc", strings.NewReader(string(body)))
// Even a well-formed digest cannot help: there is no key to verify against.
req.Header.Set(mpcSigHeader, mpcSign("", body))
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 — an unconfigured secret must fail closed", resp.StatusCode)
}
}
// TestMPCWebhook_CorrectSignatureReachesTheRail proves the fix did not simply
// refuse everyone: a genuinely signed delivery passes signature
// validation and is processed (the header the real sender uses is selected, the
// digest over the raw body matches, and the handler proceeds past the 401).
func TestMPCWebhook_CorrectSignatureReachesTheRail(t *testing.T) {
const secret = "whsec_mpc"
registerMPC(t, secret)
body := mpcSettlementEvent("evt_genuine")
r := newTestEngine()
req := httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/mpc", strings.NewReader(string(body)))
req.Header.Set(mpcSigHeader, mpcSign(secret, body))
resp, terr := r.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest {
t.Fatalf("status = %d, want the signature to be ACCEPTED for a correctly signed delivery", resp.StatusCode)
}
}
// TestPickSignatureHeader_MPC proves the real sender's header is selected, both
// on the mpc hint and through the generic candidate list. Without this the
// verified path is unreachable for a genuine delivery.
func TestPickSignatureHeader_MPC(t *testing.T) {
for _, hint := range []string{"mpc", ""} {
h := http.Header{}
h.Set(mpcSigHeader, "sig-value")
if got := pickSignatureHeader(h, hint); got != "sig-value" {
t.Errorf("hint %q: pickSignatureHeader = %q, want the X-Webhook-Signature value", hint, got)
}
}
}