Hanzo CI/CD / cicd (push) Successful in 18s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 1m38s
CI/CD / image (push) Canceled after 0s
CI/CD / rollout (push) Canceled after 0s
CI/CD / reach (push) Canceled after 0s
CI/CD / fanout (push) Canceled after 0s
CI/CD / receipt (push) Canceled after 0s
Upstream landed the v1.23 verb migration (Graft/Add/Mount folded into Use). This
is the half that was missing, and it is the half that made tests lie.
App.Test used to skip prepare, which installs the deferred projections — /mcp, the
OpenAPI document, the op-call plane, the plugin route. So those four addresses
answered 404 under test and 200 in production, and the papering-over was an
exported Prepare each caller had to remember. zip v1.24.1 makes Test prepare;
apps/ai's MCP door test passes because of that, not because of anything here.
414 call sites move from app.Fiber().Test(...) to app.Test(...) with
zip.TestConfig. That is the point of the escape hatch living on the concrete type:
reaching through it bypasses what App.Test does, so the tests most wanting to
exercise the real program were the ones that did not. Sites whose receiver is a
raw fiber app keep fiber's type — the two are not interchangeable and pretending
otherwise is how the first sweep broke things.
Also: the multi-line `Use(func(c *zip.Ctx) error {…})` literals in tests, which
the verb migration missed because they fail vet rather than build; and the last
`.Prepare()` calls, now that it is implicit.
iam v1.34.11 → v1.34.12.
Measured against upstream on the same host: 103 failing packages before, 97 after
— ZERO new, 6 fixed. The remainder is the macOS SQLCipher limit (no tmpfs for the
pure-Go codec), unrelated and unchanged.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
package cloud
|
|
|
|
// Money-path tests for the issue #70 edge enforcement: the spend-cap 402, the
|
|
// soft-warn header, and the per-scope rate limit — all driven end-to-end through
|
|
// the real zip stack against a fake commerce that controls each verdict.
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/cloud/apps/metering"
|
|
"github.com/hanzoai/cloud/plane"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// capCommerce answers the metering client's balance and spend-cap authorize
|
|
// calls. Each verdict is fully controlled per test.
|
|
//
|
|
// It also serves GET /v1/billing/alerts, which nothing may call any more: the
|
|
// rate rules come over the plane now (see middleware_ratelimit_plane_test.go),
|
|
// because that HTTP fetch was the in-process self-dispatch that 502'd. alertsHits
|
|
// stays here as the tripwire — a caller that goes back to the old wire trips it.
|
|
type capCommerce struct {
|
|
balanceBody string // GET /v1/billing/balance (default funded).
|
|
authorize string // GET /v1/billing/alerts/authorize (the cap verdict).
|
|
|
|
alertsHits atomic.Int32
|
|
}
|
|
|
|
func (f *capCommerce) server(t *testing.T) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/v1/billing/balance", func(w http.ResponseWriter, r *http.Request) {
|
|
body := f.balanceBody
|
|
if body == "" {
|
|
body = `{"available":100000}`
|
|
}
|
|
_, _ = io.WriteString(w, body)
|
|
})
|
|
mux.HandleFunc("/v1/billing/alerts/authorize", func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.WriteString(w, f.authorize)
|
|
})
|
|
mux.HandleFunc("/v1/billing/alerts", func(w http.ResponseWriter, r *http.Request) {
|
|
f.alertsHits.Add(1)
|
|
_, _ = io.WriteString(w, `[]`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// (i) Funded caller, but the scope's HARD cap is exceeded → 402 spend_cap_exceeded
|
|
// (DISTINCT from insufficient_balance), handler NOT run.
|
|
func TestBillingGate_SpendCapExceeded402(t *testing.T) {
|
|
fc := &capCommerce{authorize: `{"allow":false,"reason":"spend_cap","capCents":100,"spentCents":100}`}
|
|
srv := fc.server(t)
|
|
|
|
var ran atomic.Bool
|
|
app := newGateApp(t, mustClient(t, srv.URL, false), &ran)
|
|
|
|
resp := doReq(t, app)
|
|
if resp.StatusCode != http.StatusPaymentRequired {
|
|
t.Fatalf("status = %d, want 402", resp.StatusCode)
|
|
}
|
|
if ran.Load() {
|
|
t.Fatal("handler ran despite spend cap exceeded")
|
|
}
|
|
assertErrorCode(t, resp, "spend_cap_exceeded")
|
|
}
|
|
|
|
// (ii) Funded, under cap but over the soft threshold → handler runs (200) and the
|
|
// response carries X-Spend-Warn with the utilization percent.
|
|
func TestBillingGate_SpendWarnHeader(t *testing.T) {
|
|
fc := &capCommerce{authorize: `{"allow":true,"reason":"","warnPct":90}`}
|
|
srv := fc.server(t)
|
|
|
|
var ran atomic.Bool
|
|
app := newGateApp(t, mustClient(t, srv.URL, false), &ran)
|
|
|
|
resp := doReq(t, app)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if !ran.Load() {
|
|
t.Fatal("handler did not run on an allowed (warned) request")
|
|
}
|
|
if got := resp.Header.Get("X-Spend-Warn"); got != "90" {
|
|
t.Fatalf("X-Spend-Warn = %q, want 90", got)
|
|
}
|
|
}
|
|
|
|
// (MED-4) A resource-path spend-cap denial renders the DISTINCT 402
|
|
// spend_cap_exceeded — mirroring the edge gate — never the 503 out-of-funds shape
|
|
// (wrong code + retry storm). One handler drives DenyResource with each error.
|
|
func TestDenyResource_SpendCapDistinct402(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
wantCode int
|
|
wantBody string
|
|
}{
|
|
{"spend_cap", metering.ErrSpendCapExceeded, http.StatusPaymentRequired, "spend_cap_exceeded"},
|
|
{"insufficient", metering.ErrInsufficientBalance, http.StatusPaymentRequired, "insufficient_balance"},
|
|
}
|
|
for _, tc := range cases {
|
|
app := zip.New(zip.Config{})
|
|
app.Post("/v1/x", func(c *zip.Ctx) error { return DenyResource(c, tc.err) })
|
|
resp, err := app.Test(httptest.NewRequest(http.MethodPost, "/v1/x", nil))
|
|
if err != nil {
|
|
t.Fatalf("%s: Test: %v", tc.name, err)
|
|
}
|
|
if resp.StatusCode != tc.wantCode {
|
|
t.Fatalf("%s: status = %d, want %d", tc.name, resp.StatusCode, tc.wantCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if !containsSub(string(body), `"code":"`+tc.wantBody+`"`) {
|
|
t.Fatalf("%s: body %q missing %q", tc.name, string(body), tc.wantBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
// rateApp wires an app with ONLY the scope rate limiter in front of a handler.
|
|
func rateApp(t *testing.T, m *metering.Client) *zip.App {
|
|
t.Helper()
|
|
app := zip.New(zip.Config{})
|
|
app.Use(ScopeRateLimit(m, nil))
|
|
app.Post("/v1/agent/run", func(c *zip.Ctx) error {
|
|
return c.JSON(http.StatusOK, map[string]string{"ok": "true"})
|
|
})
|
|
return app
|
|
}
|
|
|
|
func rateReq(t *testing.T, app *zip.App, org, project string) *http.Response {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/agent/run", nil)
|
|
req.Header.Set("X-Org-Id", org)
|
|
req.Header.Set("X-User-Id", "alice")
|
|
if project != "" {
|
|
req.Header.Set("X-Project-Id", project)
|
|
}
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatalf("Test request: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// (iii) + (iv) Rate limit: a scope with rpm=2 admits 2 requests in the window then
|
|
// 429s the 3rd — and the limit is per (org, project, service): a different org
|
|
// and a different project are NOT throttled by org A / project P's rule.
|
|
//
|
|
// The rules arrive over the plane; the fake commerce is still up so the tripwire
|
|
// on its retired /v1/billing/alerts route can prove nothing fetched them by HTTP.
|
|
func TestScopeRateLimit_PerScope429AndIsolation(t *testing.T) {
|
|
servePlaneRules(t, map[string][]plane.ScopeRule{
|
|
// org "hanzo": a 2 rpm cap scoped to project "P".
|
|
"hanzo": {{Project: "P", RateLimitRpm: 2}},
|
|
// org "other": no rules → unlimited.
|
|
}, nil)
|
|
fc := &capCommerce{}
|
|
srv := fc.server(t)
|
|
app := rateApp(t, mustClient(t, srv.URL, false))
|
|
t.Cleanup(func() {
|
|
if n := fc.alertsHits.Load(); n != 0 {
|
|
t.Errorf("GET /v1/billing/alerts was called %d times — the rate rules must come over the plane, not by dispatching this app back into itself", n)
|
|
}
|
|
})
|
|
|
|
// hanzo / project P: 2 pass, 3rd is 429.
|
|
if code := rateReq(t, app, "hanzo", "P").StatusCode; code != 200 {
|
|
t.Fatalf("P req1 = %d, want 200", code)
|
|
}
|
|
if code := rateReq(t, app, "hanzo", "P").StatusCode; code != 200 {
|
|
t.Fatalf("P req2 = %d, want 200", code)
|
|
}
|
|
resp3 := rateReq(t, app, "hanzo", "P")
|
|
if resp3.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("P req3 = %d, want 429", resp3.StatusCode)
|
|
}
|
|
if got := resp3.Header.Get("X-RateLimit-Limit"); got != "2" {
|
|
t.Fatalf("X-RateLimit-Limit = %q, want 2", got)
|
|
}
|
|
if got := resp3.Header.Get("X-RateLimit-Remaining"); got != "0" {
|
|
t.Fatalf("X-RateLimit-Remaining = %q, want 0", got)
|
|
}
|
|
|
|
// Project isolation: project Q (same org) is NOT covered by P's rule.
|
|
for i := 0; i < 5; i++ {
|
|
if code := rateReq(t, app, "hanzo", "Q").StatusCode; code != 200 {
|
|
t.Fatalf("Q req%d = %d, want 200 (project P's limit must not gate Q)", i+1, code)
|
|
}
|
|
}
|
|
|
|
// Org isolation: org "other" has no rules — never throttled by hanzo's rule.
|
|
for i := 0; i < 5; i++ {
|
|
if code := rateReq(t, app, "other", "P").StatusCode; code != 200 {
|
|
t.Fatalf("other req%d = %d, want 200 (org hanzo's limit must not gate org other)", i+1, code)
|
|
}
|
|
}
|
|
}
|