mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
IsSuperAdmin folded the case of the home org and fell back to the owner claim, the org of the application that minted the token, and the money bits were granted on X-User-Owner == admin. Cloud writes X-User-Owner for an admin-org application and an admin-org person's API key as well as for a person, so either could mint money and edit the catalog here while being nobody in the rest of the same process. Every gate now reads X-User-IsAdmin, which the edge strips and writes from authz.Claims.Sudo alone; the standalone EdgeAuth decides it with that predicate over the verified token's signed membership set. X-User-IsAdmin no longer doubles as the org-admin bit, which is X-User-IsOrgAdmin. No machine is SuperAdmin. Global admin is SuperAdmin wherever it named this predicate.
223 lines
9.1 KiB
Go
223 lines
9.1 KiB
Go
package billing
|
|
|
|
// credit_test.go is the acceptance suite for POST /v1/billing/credit — the ONE
|
|
// mint-gated way credit enters an org ledger. It proves the money-critical core:
|
|
// - service token → grant (the cloud-api path)
|
|
// - SuperAdmin → grant (the human superadmin path)
|
|
// - org admin / plain user / no auth → 403/401, NO grant (a user cannot
|
|
// credit themselves — the whole point of the consolidation)
|
|
// - idempotencyKey → at most ONE grant
|
|
// - starter params (billing/credit constants) compose into a starter grant
|
|
// - the grant is READ by the balance path (spendable immediately)
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/auth"
|
|
"github.com/hanzoai/commerce/billing/credit"
|
|
"github.com/hanzoai/commerce/util/bit"
|
|
"github.com/hanzoai/commerce/util/permission"
|
|
"github.com/hanzoai/commerce/util/test/ae"
|
|
)
|
|
|
|
// postCredit drives POST /v1/billing/credit through the REAL Route() behind seed.
|
|
func postCredit(eng *zip.App, tok, orgHdr, body string) *http.Response {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/billing/credit", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if tok != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
}
|
|
if orgHdr != "" {
|
|
req.Header.Set("X-Org-Id", orgHdr)
|
|
}
|
|
resp, _ := eng.Test(req)
|
|
return resp
|
|
}
|
|
|
|
func decodeJSON(t *testing.T, resp *http.Response) map[string]any {
|
|
t.Helper()
|
|
var out map[string]any
|
|
raw := respBodyStr(resp)
|
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
t.Fatalf("bad json (%d): %v (%s)", resp.StatusCode, err, raw)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── the money assertion: only a mint principal may credit ────────────────────
|
|
|
|
// TestCredit_PlatformAppGrants proves the legitimate money path: the internal
|
|
// service token (cloud-api → commerce) appends a grant (201) end-to-end.
|
|
func TestCredit_PlatformAppGrants(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
resp := postCredit(eng, "", "creditorg", `{"org":"creditorg","amountCents":500,"reason":"welcome","tag":"starter-credit"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("service-token credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
out := decodeJSON(t, resp)
|
|
if id, _ := out["id"].(string); id == "" {
|
|
t.Fatalf("credit response missing id: %v", out)
|
|
}
|
|
if amt, _ := out["amountCents"].(float64); int64(amt) != 500 {
|
|
t.Fatalf("amountCents=%v, want 500", out["amountCents"])
|
|
}
|
|
}
|
|
|
|
// TestCredit_SuperAdminGrants proves the human superadmin (owner=="admin") may
|
|
// credit any org named in the body — no X-Org-Id needed; the org is the account.
|
|
func TestCredit_SuperAdminGrants(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.SetContext(ctx)
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.Admin|permission.Live))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "admin", Sudo: true, IsAdmin: true}) // SuperAdmin
|
|
})
|
|
resp := postCredit(eng, "", "", `{"org":"adminorg","amountCents":1000,"reason":"comp"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("SuperAdmin credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_OrgAdminDenied is THE money assertion: an org-level admin (org owner,
|
|
// NOT a SuperAdmin, NOT the service token) is 403 — it can never self-credit,
|
|
// even though it holds the org Admin bit and passes TokenRequired(Admin).
|
|
func TestCredit_OrgAdminDenied(t *testing.T) {
|
|
eng := engineWithSeed(orgAdminSeed) // owner="acme", IsAdmin=true (org-level)
|
|
resp := postCredit(eng, "", "", `{"org":"acme","amountCents":100000,"reason":"self-credit-attempt"}`)
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("org-admin self-credit: status=%d body=%s, want 403 (a user must NOT credit itself)",
|
|
resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_PlainUserDenied proves a normal authenticated user (no Admin bit) is
|
|
// refused (never a 2xx grant).
|
|
func TestCredit_PlainUserDenied(t *testing.T) {
|
|
eng := engineWithSeed(func(c *zip.Ctx) {
|
|
c.Locals("iam_authenticated", true)
|
|
c.Locals("permissions", bit.Field(permission.None))
|
|
c.Locals("iam_claims", &auth.IAMClaims{Owner: "acme"})
|
|
})
|
|
resp := postCredit(eng, "", "acme", `{"org":"acme","amountCents":500,"reason":"x"}`)
|
|
if resp.StatusCode < 400 {
|
|
t.Fatalf("plain user credit: status=%d body=%s, want a rejection (>=400), never a grant",
|
|
resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// TestCredit_NoAuthDenied proves an unauthenticated request never grants.
|
|
func TestCredit_NoAuthDenied(t *testing.T) {
|
|
eng := engineWithSeed(nil)
|
|
resp := postCredit(eng, "", "", `{"org":"acme","amountCents":500,"reason":"x"}`)
|
|
if resp.StatusCode < 400 {
|
|
t.Fatalf("no-auth credit: status=%d body=%s, want a rejection (>=400)", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
}
|
|
|
|
// ── idempotency ──────────────────────────────────────────────────────────────
|
|
|
|
// TestCredit_IdempotentOnKey proves the same idempotencyKey credits AT MOST ONCE:
|
|
// the second call replays the first grant (200, same id) and the balance holds
|
|
// exactly one grant.
|
|
func TestCredit_IdempotentOnKey(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
body := `{"org":"idemorg","amountCents":250,"reason":"promo","idempotencyKey":"once-2026"}`
|
|
|
|
first := postCredit(eng, "", "idemorg", body)
|
|
if first.StatusCode != http.StatusCreated {
|
|
t.Fatalf("first credit: status=%d body=%s, want 201", first.StatusCode, respBodyStr(first))
|
|
}
|
|
firstID, _ := decodeJSON(t, first)["id"].(string)
|
|
|
|
second := postCredit(eng, "", "idemorg", body)
|
|
if second.StatusCode != http.StatusOK {
|
|
t.Fatalf("replayed credit: status=%d body=%s, want 200 (idempotent replay)", second.StatusCode, respBodyStr(second))
|
|
}
|
|
secondID, _ := decodeJSON(t, second)["id"].(string)
|
|
if firstID == "" || firstID != secondID {
|
|
t.Fatalf("idempotent replay returned a different grant: first=%q second=%q", firstID, secondID)
|
|
}
|
|
|
|
// The money invariant: balance == exactly ONE grant, never doubled.
|
|
if bal := getBalanceCents(t, eng, "", "idemorg"); bal != 250 {
|
|
t.Fatalf("after two same-key credits, balance=%d cents, want exactly 250 (no double-grant)", bal)
|
|
}
|
|
}
|
|
|
|
// ── starter composition + ledger read ────────────────────────────────────────
|
|
|
|
// TestCredit_StarterComposition proves the starter credit is now just a
|
|
// parameterized call driven by the billing/credit constants, and it lands and is
|
|
// readable by the balance path (spendable).
|
|
func TestCredit_StarterComposition(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
expiry := time.Now().AddDate(0, 0, credit.StarterCreditDays).UTC().Format(time.RFC3339)
|
|
body := fmt.Sprintf(`{"org":"starterorg","amountCents":%d,"reason":"welcome","tag":%q,"expiresAt":%q}`,
|
|
credit.StarterCreditCents, credit.StarterCreditTag, expiry)
|
|
|
|
resp := postCredit(eng, "", "starterorg", body)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("starter credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
out := decodeJSON(t, resp)
|
|
if tag, _ := out["tag"].(string); tag != credit.StarterCreditTag {
|
|
t.Fatalf("tag=%q, want %q (domain constant drives it)", out["tag"], credit.StarterCreditTag)
|
|
}
|
|
|
|
// Readable by the balance path = spendable.
|
|
if bal := getBalanceCents(t, eng, "", "starterorg"); bal != int64(credit.StarterCreditCents) {
|
|
t.Fatalf("starter credit not visible in balance: got %d cents, want %d", bal, credit.StarterCreditCents)
|
|
}
|
|
}
|
|
|
|
// TestCredit_LedgerReadableByBalance proves a plain (non-starter) grant nets into
|
|
// the org balance the gateway gate reads.
|
|
func TestCredit_LedgerReadableByBalance(t *testing.T) {
|
|
ctx := ae.NewContext()
|
|
defer ctx.Close()
|
|
|
|
eng := engineWithSeed(func(c *zip.Ctx) { c.SetContext(ctx); platformApp(c) })
|
|
if resp := postCredit(eng, "", "ledgerorg", `{"org":"ledgerorg","amountCents":700,"reason":"grant"}`); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("credit: status=%d body=%s, want 201", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
if bal := getBalanceCents(t, eng, "", "ledgerorg"); bal != 700 {
|
|
t.Fatalf("credit not readable by balance: got %d cents, want 700", bal)
|
|
}
|
|
}
|
|
|
|
// getBalanceCents reads GET /v1/billing/balance for the org pool (subject == org).
|
|
func getBalanceCents(t *testing.T, eng *zip.App, tok, orgID string) int64 {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/billing/balance?user="+orgID+"¤cy=usd", nil)
|
|
req.Header.Set("X-Org-Id", orgID)
|
|
resp, terr := eng.Test(req)
|
|
if terr != nil {
|
|
t.Fatalf("balance Test: %v", terr)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /balance: status=%d body=%s", resp.StatusCode, respBodyStr(resp))
|
|
}
|
|
bal, _ := decodeJSON(t, resp)["balance"].(float64)
|
|
return int64(bal)
|
|
}
|