mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
119 lines
4.2 KiB
Go
119 lines
4.2 KiB
Go
package affiliate
|
|
|
|
// payout_gate_test.go proves the [MEDIUM] C1 miss is closed: the OSS-contributor
|
|
// treasury payout machinery (executePayouts and friends) is service-token /
|
|
// platform-global-admin ONLY — an org-level admin can no longer drive treasury
|
|
// disbursement (CreditGrants / Stripe transfers / on-chain HUSD).
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// mountAffiliate mounts the real affiliate Route() behind a seed and returns the app
|
|
// plus the concrete POST path whose registration ends with the given suffix. Fiber
|
|
// exposes no handler names (gin's ri.Handler is gone), so routes are matched by their
|
|
// path tail instead.
|
|
func mountAffiliate(t *testing.T, seed func(*zip.Ctx), pathSuffix string) (*zip.App, string) {
|
|
t.Helper()
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
if seed != nil {
|
|
app.Use(zip.H(func(c *zip.Ctx) error { seed(c); return c.Next() }))
|
|
}
|
|
Route(app.Group("/v1"))
|
|
|
|
for _, ri := range app.Fiber().GetRoutes() {
|
|
if ri.Method == http.MethodPost && strings.HasSuffix(ri.Path, pathSuffix) {
|
|
return app, ri.Path
|
|
}
|
|
}
|
|
t.Fatalf("route with path suffix %q not found", pathSuffix)
|
|
return nil, ""
|
|
}
|
|
|
|
// TestPayoutExecute_OrgAdminDenied is the acceptance test: an org admin POSTing
|
|
// the payout executor is 403 — it can no longer disburse the treasury.
|
|
func TestPayoutExecute_OrgAdminDenied(t *testing.T) {
|
|
app, path := mountAffiliate(t, func(c *zip.Ctx) {
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
|
|
}, "/payouts/execute")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, path+"?execute=true", bytes.NewBufferString(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("test request: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("org-admin %s: status=%d body=%s, want 403", path, resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestPayoutCalculate_OrgAdminDenied proves the sibling payout-family write is
|
|
// gated too (RED grouped calculate/sbom with execute).
|
|
func TestPayoutCalculate_OrgAdminDenied(t *testing.T) {
|
|
app, path := mountAffiliate(t, func(c *zip.Ctx) {
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme", IsAdmin: true})
|
|
}, "/payouts/calculate")
|
|
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"totalRevenueCents":100000}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("test request: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("org-admin %s: status=%d body=%s, want 403", path, resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// TestPayoutExecute_ServiceTokenReaches proves the legitimate CronJob / cloud-api
|
|
// path is preserved: the internal service token passes the gate (NOT 403). The
|
|
// dry-run executor runs behind it.
|
|
func TestPayoutExecute_PlatformAppReaches(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
platform := func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
cl := &auth.IAMClaims{Owner: "admin"}
|
|
cl.Subject = "app_platform"
|
|
c.Locals("iam_claims", cl)
|
|
}
|
|
app, path := mountAffiliate(t, platform, "/payouts/execute")
|
|
|
|
// dry-run (no ?execute=true) — the gate must ADMIT the platform's application.
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Org-Id", "payoutorg")
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("test request: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusForbidden {
|
|
t.Fatalf("service-token %s was 403 — the gate wrongly denied the internal service path", path)
|
|
}
|
|
}
|