mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
211 lines
7.8 KiB
Go
211 lines
7.8 KiB
Go
// Package commerce — integration test proving P8-H1 is fixed: the store-
|
|
// backed /v1/commerce/org (public) route
|
|
// are wired into the production commerce binary, not just attached inside a
|
|
// per-test router.
|
|
//
|
|
// We boot the real commerce App via NewWithConfig → Bootstrap. Bootstrap
|
|
// initializes the hanzo/base-backed CommerceStore, IAM middleware (disabled
|
|
// in this test via IAM.Enabled=false), gin router, and calls setupRoutes. No
|
|
// mocks, no test-only wiring — this is the shape that ships.
|
|
package commerce
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
luxlog "github.com/luxfi/log"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// bootTestCommerce constructs a real App with the lightest viable config and
|
|
// runs Bootstrap. Infra services that fail to connect degrade — KMS disabled,
|
|
// NATS absent, Stripe seed gated on STRIPE_SECRET_KEY (unset), IAM disabled.
|
|
// The only thing that MUST work is the commerce/store bootstrap + route wire.
|
|
func bootTestCommerce(t *testing.T) *App {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
cfg := &Config{
|
|
DataDir: filepath.Join(dir, "data"),
|
|
Dev: false,
|
|
Secret: "test-secret",
|
|
HTTPAddr: "127.0.0.1:0",
|
|
QueryTimeout: 30e9,
|
|
}
|
|
cfg.IAM.Enabled = false // no JWKS fetch; admin routes still register, caller JWT absent → 401
|
|
|
|
// Ensure we do not touch the environment's STRIPE / SQL vars.
|
|
t.Setenv("STRIPE_SECRET_KEY", "")
|
|
t.Setenv("COMMERCE_STRIPE_SEED", "false")
|
|
t.Setenv("SQL_URL", "")
|
|
t.Setenv("COMMERCE_DATA_DIR", cfg.DataDir)
|
|
t.Setenv("COMMERCE_BASE_URL", "")
|
|
|
|
app := NewWithConfig(cfg)
|
|
if err := app.Bootstrap(); err != nil {
|
|
t.Fatalf("Bootstrap: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = app.Shutdown()
|
|
})
|
|
return app
|
|
}
|
|
|
|
// probe issues a request directly against the booted router, bypassing the
|
|
// HTTP server listen step but exercising the exact same gin.Engine.
|
|
func probe(t *testing.T, app *App, method, path string, host string, body []byte) *http.Response {
|
|
t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
rdr = bytes.NewReader(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, rdr)
|
|
if host != "" {
|
|
req.Host = host
|
|
}
|
|
resp, terr := app.Router.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("Test: %v", terr)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// TestRoutes_WiredInProduction proves P8-H1 is fixed: the store-backed
|
|
// /v1/commerce/org endpoint is wired in the
|
|
// booted commerce binary's router. If either returns the NoRoute 404
|
|
// body `{"error":"not found"}` (MountSPA's isAPIPath branch) the wiring is
|
|
// missing.
|
|
//
|
|
// We do NOT assert specific success semantics — authentication is disabled
|
|
// and the org collection is empty — only that the router recognizes the
|
|
// paths and dispatches to a registered handler rather than NoRoute.
|
|
func TestRoutes_WiredInProduction(t *testing.T) {
|
|
app := bootTestCommerce(t)
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
host string
|
|
body []byte
|
|
}{
|
|
{name: "public_org_lookup", method: http.MethodGet, path: "/v1/commerce/org", host: "pay.example.test"},
|
|
{name: "billing_plans", method: http.MethodGet, path: "/v1/billing/plans", host: "pay.example.test"},
|
|
{name: "billing_settings", method: http.MethodGet, path: "/v1/billing/settings", host: "pay.example.test"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resp := probe(t, app, tc.method, tc.path, tc.host, tc.body)
|
|
defer resp.Body.Close()
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
body := string(bodyBytes)
|
|
|
|
// NoRoute SPA fallback for API paths returns exactly
|
|
// `{"error":"not found"}`. If we see that, the endpoint is NOT
|
|
// wired. Any other body proves the route reached a handler.
|
|
if body == `{"error":"not found"}` {
|
|
t.Fatalf("%s %s reached NoRoute SPA fallback — route not wired. status=%d body=%s",
|
|
tc.method, tc.path, resp.StatusCode, body)
|
|
}
|
|
|
|
// Sanity: must NOT be 405 Method Not Allowed.
|
|
if resp.StatusCode == http.StatusMethodNotAllowed {
|
|
t.Fatalf("%s %s returned 405 — route wiring method mismatch", tc.method, tc.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRoutes_PublicTenantResolvesOrg proves GET /v1/commerce/org resolves
|
|
// the org from the IAM org (host→brand→org), returning 200 with an
|
|
// org JSON — NOT a 404 against a separate org registry. Even with
|
|
// no org row and no Square env in the test binary, resolution degrades to the
|
|
// deployment default brand + env, so the pay SPA always receives a usable
|
|
// org carrying `name` (the org slug) and a `square` block. This is the fix
|
|
// for the pay.hanzo.ai "unknown tenant" 404 + dead card form.
|
|
func TestRoutes_PublicTenantResolvesOrg(t *testing.T) {
|
|
app := bootTestCommerce(t)
|
|
|
|
resp := probe(t, app, http.MethodGet, "/v1/commerce/org", "pay.p8.test", nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status = %d, want 200 (org). body=%s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("org JSON decode: %v", err)
|
|
}
|
|
if name, _ := body["name"].(string); name == "" {
|
|
t.Fatalf("org JSON missing name (org slug): %v", body)
|
|
}
|
|
if _, ok := body["square"]; !ok {
|
|
t.Fatalf("org JSON missing square block (card form needs it): %v", body)
|
|
}
|
|
}
|
|
|
|
// TestRoutes_NoRouteRefunds proves no route initiates or records a refund: each
|
|
// former refund path reaches the NoRoute fallback (or a 405), never a handler. A
|
|
// refund is recorded only from the processor's signed webhook.
|
|
func TestRoutes_NoRouteRefunds(t *testing.T) {
|
|
app := bootTestCommerce(t)
|
|
|
|
for _, path := range []string{
|
|
"/v1/billing/refunds",
|
|
"/v1/billing/refund",
|
|
"/v1/order/ord_x/refund",
|
|
"/v1/payment/pay_x/refund",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
resp := probe(t, app, http.MethodPost, path, "pay.example.test", []byte(`{"amount":100}`))
|
|
defer resp.Body.Close()
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
if body := string(bodyBytes); body != `{"error":"not found"}` && resp.StatusCode != http.StatusMethodNotAllowed {
|
|
t.Fatalf("POST %s reached a handler: status=%d body=%s", path, resp.StatusCode, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRoutes_OneWebhookAddress pins the one payment-provider webhook route. It is
|
|
// declared once, in setupRoutes, and answers at /v1/commerce/webhooks/<provider>
|
|
// both standalone and mounted on a host's app; the /v1/billing spelling is gone,
|
|
// in both. A delivery with no signature header reaching the handler is its 400 —
|
|
// which proves the route resolved to HandleProviderWebhook and nothing earlier.
|
|
func TestRoutes_OneWebhookAddress(t *testing.T) {
|
|
standalone := bootTestCommerce(t)
|
|
mounted := zip.New(zip.Config{Logger: luxlog.New("test")})
|
|
if err := Mount(mounted, t.TempDir(), luxlog.New("test"), nil); err != nil {
|
|
t.Fatalf("Mount: %v", err)
|
|
}
|
|
send := map[string]func(*http.Request) (*http.Response, error){
|
|
"standalone": func(r *http.Request) (*http.Response, error) { return standalone.Router.Test(r) },
|
|
"mounted": func(r *http.Request) (*http.Response, error) { return mounted.Test(r) },
|
|
}
|
|
for mode, do := range send {
|
|
resp, err := do(httptest.NewRequest(http.MethodPost, "/v1/commerce/webhooks/square", strings.NewReader(`{}`)))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", mode, err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusBadRequest || !strings.Contains(string(body), "missing webhook signature header") {
|
|
t.Errorf("%s: POST /v1/commerce/webhooks/square = %d %s, want the handler's 400", mode, resp.StatusCode, body)
|
|
}
|
|
resp, err = do(httptest.NewRequest(http.MethodPost, "/v1/billing/webhooks/square", strings.NewReader(`{}`)))
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", mode, err)
|
|
}
|
|
body, _ = io.ReadAll(resp.Body)
|
|
if strings.Contains(string(body), "webhook signature") {
|
|
t.Errorf("%s: POST /v1/billing/webhooks/square reached the webhook handler (%d %s); it must have one address", mode, resp.StatusCode, body)
|
|
}
|
|
}
|
|
}
|