Files
commerce/api/checkout/sessions_authz_test.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

89 lines
3.3 KiB
Go

package checkout
import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// mountCheckout registers the real checkout route stack (including the
// publishedRequired auth middleware on /sessions) on a fresh zip app, exactly
// as the binary does at /v1.
func mountCheckout(t *testing.T) *zip.App {
t.Helper()
app := zip.New(zip.Config{DisableStartupMessage: true})
Route(app.Group("/v1"))
return app
}
// TestSessions_AnonymousMintBlocked is the core P0 regression: POST
// /v1/checkout/sessions with NO valid principal must be rejected (401/403)
// BEFORE any org resolution or Square call — no anonymous path can mint a
// payment link. We assert this for a naked request AND for every client-supplied
// org-selector the old handler honored (body org, ?org, X-Org-Id, X-Hanzo-Org):
// every one must fail closed with no checkout link in the response.
func TestSessions_AnonymousMintBlocked(t *testing.T) {
app := mountCheckout(t)
const body = `{"org":"hanzo","tenant":"hanzo","currency":"USD",` +
`"successUrl":"https://hanzo.ai/ok",` +
`"items":[{"name":"x","amount":100,"quantity":1}]}`
cases := []struct {
name string
url string
headers map[string]string
}{
{"no auth, body org", "/v1/checkout/sessions", nil},
{"query ?org override", "/v1/checkout/sessions?org=hanzo", nil},
{"spoofed X-Org-Id", "/v1/checkout/sessions", map[string]string{"X-Org-Id": "hanzo"}},
{"spoofed X-Hanzo-Org", "/v1/checkout/sessions", map[string]string{"X-Hanzo-Org": "hanzo"}},
{"spoofed X-IAM-Org", "/v1/checkout/sessions", map[string]string{"X-IAM-Org": "hanzo"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, tc.url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range tc.headers {
req.Header.Set(k, v)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("anon %s: request error: %v", tc.name, err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden {
t.Fatalf("anon %s: status = %d, want 401/403 (anonymous mint must be closed)", tc.name, resp.StatusCode)
}
// Fail closed: no minted link, ever.
if b := string(bodyBytes); strings.Contains(b, "checkoutUrl") ||
strings.Contains(b, "square.link") || strings.Contains(b, "squareup") {
t.Fatalf("anon %s leaked a checkout link (status %d): %s", tc.name, resp.StatusCode, b)
}
})
}
}
// TestCheckoutSessionRequest_NoClientOrgField pins the "one org source" contract:
// the request body must NOT carry an org/tenant selector — the org is derived
// solely from the authenticated principal. A future re-add of the field would
// re-open the cross-tenant mint hole, so guard it at compile-adjacent time.
func TestCheckoutSessionRequest_NoClientOrgField(t *testing.T) {
tp := reflect.TypeOf(checkoutSessionRequest{})
for i := 0; i < tp.NumField(); i++ {
name := strings.Split(strings.ToLower(tp.Field(i).Tag.Get("json")), ",")[0]
if name == "org" || name == "tenant" {
t.Fatalf("checkoutSessionRequest.%s exposes a client-supplied %q field; "+
"org MUST come only from the authenticated principal", tp.Field(i).Name, name)
}
}
}