Files
commerce/metering/metering_scopecap_test.go

176 lines
6.4 KiB
Go

package metering_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/commerce/metering"
)
// routingCommerce is a fake commerce that answers the balance, spend-cap
// authorize, and alerts endpoints independently — so AuthorizeVerdict's two
// checks (funds, then cap) can be driven separately.
type routingCommerce struct {
balance string // GET /v1/billing/balance
authorize string // GET /v1/billing/alerts/authorize
authCode int // status for the authorize endpoint (0 => 200)
alerts string // GET /v1/billing/alerts
}
func (rc *routingCommerce) server(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(rc.balance))
})
mux.HandleFunc("/v1/billing/alerts/authorize", func(w http.ResponseWriter, r *http.Request) {
if rc.authCode != 0 {
w.WriteHeader(rc.authCode)
}
_, _ = w.Write([]byte(rc.authorize))
})
mux.HandleFunc("/v1/billing/alerts", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(rc.alerts))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func capClient(t *testing.T, srv *httptest.Server) *metering.Client {
t.Helper()
c, err := metering.New(metering.Config{BaseURL: srv.URL, Token: "svc", Org: "acme"})
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
// Funded + a spend_cap verdict => Verdict deny(spend_cap); Authorize maps it to
// ErrSpendCapExceeded (DISTINCT from ErrInsufficientBalance).
func TestAuthorizeVerdict_SpendCap(t *testing.T) {
rc := &routingCommerce{
balance: `{"available":100000}`,
authorize: `{"allow":false,"reason":"spend_cap","capCents":100,"spentCents":100,"warnPct":0}`,
}
c := capClient(t, rc.server(t))
in := metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1, Project: "P", Service: "ai"}
v, err := c.AuthorizeVerdict(context.Background(), in)
if err != nil {
t.Fatalf("AuthorizeVerdict err: %v", err)
}
if v.Allow || v.Reason != "spend_cap" || v.CapCents != 100 || v.SpentCents != 100 {
t.Fatalf("verdict = %+v, want deny spend_cap cap/spent 100/100", v)
}
if err := c.Authorize(context.Background(), in); err != metering.ErrSpendCapExceeded {
t.Fatalf("Authorize err = %v, want ErrSpendCapExceeded", err)
}
}
// Funded + under cap but over soft threshold => allow with WarnPct passthrough.
func TestAuthorizeVerdict_Warn(t *testing.T) {
rc := &routingCommerce{
balance: `{"available":100000}`,
authorize: `{"allow":true,"reason":"","warnPct":85}`,
}
c := capClient(t, rc.server(t))
v, err := c.AuthorizeVerdict(context.Background(), metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1})
if err != nil {
t.Fatalf("AuthorizeVerdict err: %v", err)
}
if !v.Allow || v.WarnPct != 85 {
t.Fatalf("verdict = %+v, want allow warnPct 85", v)
}
if err := c.Authorize(context.Background(), metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1}); err != nil {
t.Fatalf("Authorize err = %v, want nil (allow)", err)
}
}
// Unfunded short-circuits to insufficient_balance — the cap is never consulted.
func TestAuthorizeVerdict_Insufficient(t *testing.T) {
rc := &routingCommerce{
balance: `{"available":0}`,
authorize: `{"allow":true}`,
}
c := capClient(t, rc.server(t))
in := metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1}
v, _ := c.AuthorizeVerdict(context.Background(), in)
if v.Allow || v.Reason != "insufficient_balance" {
t.Fatalf("verdict = %+v, want deny insufficient_balance", v)
}
if err := c.Authorize(context.Background(), in); err != metering.ErrInsufficientBalance {
t.Fatalf("Authorize err = %v, want ErrInsufficientBalance", err)
}
}
// A funded caller whose cap cannot be read is REFUSED: a gate that cannot decide
// does not allow. Any cap error — a missing endpoint, a 500, an unreadable body.
func TestAuthorizeVerdict_CapErrorRefuses(t *testing.T) {
for _, tc := range []struct {
body string
code int
}{{"nope", http.StatusNotFound}, {"boom", http.StatusInternalServerError}, {"not json", http.StatusOK}} {
rc := &routingCommerce{balance: `{"available":100000}`, authorize: tc.body, authCode: tc.code}
c := capClient(t, rc.server(t))
in := metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1}
if v, err := c.AuthorizeVerdict(context.Background(), in); err == nil || v.Allow {
t.Fatalf("cap %d %q: verdict %+v err %v; want a refusal", tc.code, tc.body, v, err)
}
if err := c.Authorize(context.Background(), in); err == nil || err == metering.ErrInsufficientBalance {
t.Fatalf("cap %d %q: Authorize = %v; want an unknown-cap error (503)", tc.code, tc.body, err)
}
}
}
// ProjectValidated forwards as pv=1 on the scope-authorize query (and is absent
// when false), so commerce can degrade an unvalidated project cap to soft.
func TestAuthorizeVerdict_ProjectValidatedForwarded(t *testing.T) {
var gotPV string
seen := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/billing/alerts/authorize" {
seen = true
gotPV = r.URL.Query().Get("pv")
}
if r.URL.Path == "/v1/billing/balance" {
_, _ = w.Write([]byte(`{"available":100000}`))
return
}
_, _ = w.Write([]byte(`{"allow":true}`))
}))
t.Cleanup(srv.Close)
c, err := metering.New(metering.Config{BaseURL: srv.URL, Token: "svc", Org: "acme"})
if err != nil {
t.Fatalf("New: %v", err)
}
_, _ = c.AuthorizeVerdict(context.Background(), metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1, Project: "P", ProjectValidated: true})
if !seen || gotPV != "1" {
t.Fatalf("pv (validated) = %q seen=%v, want \"1\"", gotPV, seen)
}
seen, gotPV = false, ""
_, _ = c.AuthorizeVerdict(context.Background(), metering.AuthInput{User: "acme", Org: "acme", AmountCents: 1, Project: "P", ProjectValidated: false})
if !seen || gotPV != "" {
t.Fatalf("pv (unvalidated) = %q, want \"\" (absent)", gotPV)
}
}
// ScopeRules returns only the rate-limited rows, decoded from the alerts list.
func TestScopeRules(t *testing.T) {
rc := &routingCommerce{
alerts: `[{"project":"","service":"","rateLimitRpm":0},{"project":"P","service":"ai","rateLimitRpm":5}]`,
}
c := capClient(t, rc.server(t))
rules, err := c.ScopeRules(context.Background(), "acme")
if err != nil {
t.Fatalf("ScopeRules err: %v", err)
}
if len(rules) != 1 || rules[0].Project != "P" || rules[0].Service != "ai" || rules[0].RateLimitRpm != 5 {
t.Fatalf("rules = %+v, want one {P,ai,5}", rules)
}
}