Files
multica/server/internal/handler/cloud_billing_test.go
f0c9146c4b MUL-7437: fix(api): classify disabled capabilities as non-retryable (#8487)
* fix(plugin): return 403 when API is disabled (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): classify disabled capabilities as non-retryable (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): address disabled capability review feedback (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 17:27:05 +08:00

907 lines
34 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/cloudruntime"
"github.com/multica-ai/multica/server/internal/featureflags"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// proxyExpectation captures the assertions every standard
// cloud-billing endpoint shares: it must call the cloud proxy with a
// specific method/path, must stamp X-User-ID from the authenticated
// context, and must return the upstream response untouched.
//
// Reusing this table-driven helper keeps the per-endpoint tests small
// — the interesting per-endpoint logic lives in `withQuery` /
// `withBody` / dynamic-path-param branches.
type billingProxyCase struct {
name string
method string
path string // path on OUR router, e.g. /api/cloud-billing/balance
body any // nil for GET; map / struct for POST bodies
wantPx string // expected upstream path
wantQ string // expected upstream query (encoded form), "" if none
invoke func(t *testing.T, w http.ResponseWriter, r *http.Request)
}
// TestCloudBillingProxiesForwardCorrectly walks every standard
// endpoint at once: each one must hit the right upstream path with
// the right method and the caller's user id. Single test = single
// stub configuration; we just rotate which handler we invoke. This
// is the cheapest way to keep all 7 standard endpoints covered
// without duplicating the proxy plumbing per test.
func TestCloudBillingProxiesForwardCorrectly(t *testing.T) {
cases := []billingProxyCase{
{
name: "balance",
method: http.MethodGet,
path: "/api/cloud-billing/balance",
wantPx: "/api/v1/billing/balance",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.GetCloudBillingBalance(w, r)
},
},
{
name: "transactions list with paging",
method: http.MethodGet,
path: "/api/cloud-billing/transactions?page=2&page_size=50",
wantPx: "/api/v1/billing/transactions",
wantQ: "page=2&page_size=50",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.ListCloudBillingTransactions(w, r)
},
},
{
name: "batches list",
method: http.MethodGet,
path: "/api/cloud-billing/batches?page_size=10",
wantPx: "/api/v1/billing/batches",
wantQ: "page_size=10",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.ListCloudBillingBatches(w, r)
},
},
{
name: "topups list",
method: http.MethodGet,
path: "/api/cloud-billing/topups",
wantPx: "/api/v1/billing/topups",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.ListCloudBillingTopups(w, r)
},
},
{
name: "price tiers",
method: http.MethodGet,
path: "/api/cloud-billing/price-tiers",
wantPx: "/api/v1/billing/price-tiers",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.ListCloudBillingPriceTiers(w, r)
},
},
{
name: "create checkout session",
method: http.MethodPost,
path: "/api/cloud-billing/checkout-sessions",
body: map[string]any{"tier_id": "starter"},
wantPx: "/api/v1/billing/checkout-sessions",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.CreateCloudBillingCheckoutSession(w, r)
},
},
{
name: "create portal session",
method: http.MethodPost,
path: "/api/cloud-billing/portal-sessions",
wantPx: "/api/v1/billing/portal-sessions",
invoke: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
testHandler.CreateCloudBillingPortalSession(w, r)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusOK,
Body: []byte(`{"ok":true}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := newRequest(tc.method, tc.path, tc.body)
w := httptest.NewRecorder()
tc.invoke(t, w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if !proxy.called {
t.Fatal("expected cloud proxy to be called")
}
if proxy.req.Method != tc.method {
t.Errorf("upstream method = %s, want %s", proxy.req.Method, tc.method)
}
if proxy.req.Path != tc.wantPx {
t.Errorf("upstream path = %s, want %s", proxy.req.Path, tc.wantPx)
}
if proxy.req.UserID != testUserID {
t.Errorf("upstream user_id = %q, want %q", proxy.req.UserID, testUserID)
}
if got := proxy.req.Query.Encode(); got != tc.wantQ {
t.Errorf("upstream query = %q, want %q", got, tc.wantQ)
}
// Body should be present on POST cases and absent on GET.
if tc.method == http.MethodPost && tc.body != nil && len(proxy.req.Body) == 0 {
t.Error("expected upstream body on POST, got empty")
}
if tc.method == http.MethodGet && len(proxy.req.Body) > 0 {
t.Errorf("upstream body should be empty on GET, got %s", proxy.req.Body)
}
})
}
}
// TestGetCloudBillingCheckoutSession_AppendsSessionIDToPath pins the
// dynamic-path handler. The session id flows from chi URL param into
// the upstream URL, and the upstream therefore sees a different path
// than every other billing endpoint — easy to break by accident.
func TestGetCloudBillingCheckoutSession_AppendsSessionIDToPath(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusOK,
Body: []byte(`{"order_id":"o","status":"credited"}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := newRequest(http.MethodGet, "/api/cloud-billing/checkout-sessions/cs_test_abc", nil)
req = withURLParam(req, "sessionId", "cs_test_abc")
w := httptest.NewRecorder()
testHandler.GetCloudBillingCheckoutSession(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.req.Path != "/api/v1/billing/checkout-sessions/cs_test_abc" {
t.Errorf("upstream path = %s, want /api/v1/billing/checkout-sessions/cs_test_abc", proxy.req.Path)
}
if proxy.req.UserID != testUserID {
t.Errorf("upstream user_id = %q", proxy.req.UserID)
}
}
// TestGetCloudBillingCheckoutSession_RejectsPathTraversal pins the
// defensive bail when the session_id contains characters that would
// alter URL semantics. The cloud-runtime client rejects paths missing
// the leading slash but does not otherwise sanitize, so a stray `/`
// here would re-target the upstream request.
func TestGetCloudBillingCheckoutSession_RejectsPathTraversal(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
for _, sessionID := range []string{
"cs_test/../admin",
"cs?inject=1",
"cs#frag",
} {
t.Run(sessionID, func(t *testing.T) {
req := newRequest(http.MethodGet, "/api/cloud-billing/checkout-sessions/x", nil)
req = withURLParam(req, "sessionId", sessionID)
w := httptest.NewRecorder()
testHandler.GetCloudBillingCheckoutSession(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called for invalid session_id")
}
})
}
}
// TestGetCloudBillingCheckoutSession_MissingPathParamReturns400 pins
// the no-id branch (defensive — chi shouldn't route to us without a
// param, but we guard anyway).
func TestGetCloudBillingCheckoutSession_MissingPathParamReturns400(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := newRequest(http.MethodGet, "/api/cloud-billing/checkout-sessions/", nil)
// No URL param injected.
w := httptest.NewRecorder()
testHandler.GetCloudBillingCheckoutSession(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called when session_id is missing")
}
}
// TestCloudBillingDisabledReturnsForbidden confirms self-hosted
// deployments (no cloud URL configured) get a non-retryable 403 rather than
// a cryptic upstream error.
func TestCloudBillingDisabledReturnsForbidden(t *testing.T) {
useCloudRuntimeProxy(t, &fakeCloudRuntimeProxy{enabled: false})
req := newRequest(http.MethodGet, "/api/cloud-billing/balance", nil)
w := httptest.NewRecorder()
testHandler.GetCloudBillingBalance(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
func withCloudSubscriptionWorkspace(req *http.Request, role string) *http.Request {
ctx := middleware.SetMemberContext(req.Context(), testWorkspaceID, db.Member{Role: role})
return req.WithContext(ctx)
}
func TestCloudWorkspaceSubscriptionsDisabledByDefault(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, false)
reads := map[string]func(http.ResponseWriter, *http.Request){
"/api/cloud-subscriptions/summary": testHandler.GetCloudWorkspaceSubscriptionSummary,
"/api/cloud-subscriptions/prices": testHandler.GetCloudWorkspaceSubscriptionPrices,
}
for path, invoke := range reads {
t.Run(path, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(http.MethodGet, path, nil), "member")
w := httptest.NewRecorder()
invoke(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called while the feature flag is off")
}
})
}
}
func TestCloudWorkspaceSubscriptionReadAndWritesUseScopedPaths(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
cases := []struct {
name string
method string
path string
role string
wantStatus int
wantPath string
invoke func(http.ResponseWriter, *http.Request)
}{
{
name: "member reads billing summary",
method: http.MethodGet,
path: "/api/cloud-subscriptions/summary",
role: "member",
wantStatus: http.StatusOK,
wantPath: "/api/v1/subscriptions/" + testWorkspaceID + "/summary",
invoke: testHandler.GetCloudWorkspaceSubscriptionSummary,
},
{
name: "member reads seat prices",
method: http.MethodGet,
path: "/api/cloud-subscriptions/prices",
role: "member",
wantStatus: http.StatusOK,
wantPath: "/api/v1/subscriptions/" + testWorkspaceID + "/prices",
invoke: testHandler.GetCloudWorkspaceSubscriptionPrices,
},
{
name: "admin reconciles seats",
method: http.MethodPost,
path: "/api/cloud-subscriptions/seats/reconcile",
role: "admin",
wantStatus: http.StatusOK,
wantPath: "/api/v1/subscriptions/" + testWorkspaceID + "/seats/reconcile",
invoke: testHandler.ReconcileCloudWorkspaceSubscriptionSeats,
},
{
name: "owner opens portal",
method: http.MethodPost,
path: "/api/cloud-subscriptions/portal-sessions",
role: "owner",
wantStatus: http.StatusOK,
wantPath: "/api/v1/subscriptions/" + testWorkspaceID + "/portal-sessions",
invoke: testHandler.CreateCloudWorkspaceSubscriptionPortal,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: tc.wantStatus,
Body: []byte(`{"ok":true}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(tc.method, tc.path, nil), tc.role)
if strings.Contains(tc.path, "portal-sessions") {
req.Header.Set(idempotencyKeyHeader, "portal-request-1")
}
w := httptest.NewRecorder()
tc.invoke(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.req.Method != tc.method || proxy.req.Path != tc.wantPath {
t.Fatalf("upstream = %s %s, want %s %s", proxy.req.Method, proxy.req.Path, tc.method, tc.wantPath)
}
if proxy.req.UserID != testUserID {
t.Fatalf("upstream user_id = %q, want %q", proxy.req.UserID, testUserID)
}
if proxy.req.Op != "billing" {
t.Fatalf("upstream op = %q, want billing", proxy.req.Op)
}
if strings.Contains(tc.path, "portal-sessions") && proxy.req.Headers.Get(idempotencyKeyHeader) != "portal-request-1" {
t.Fatalf("upstream idempotency key = %q", proxy.req.Headers.Get(idempotencyKeyHeader))
}
})
}
}
func TestCreateCloudWorkspaceSubscriptionCheckoutInjectsAuthoritativeWorkspaceAndEmail(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusCreated,
Body: []byte(`{"url":"https://checkout.stripe.test/session"}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := newRequest(http.MethodPost, "/api/cloud-subscriptions/checkout-sessions", map[string]any{
"workspace_id": "00000000-0000-0000-0000-000000000001",
"interval": "year",
"idempotency_key": "checkout-request-1",
"customer_email": "[email protected]",
})
req.Header.Set(idempotencyKeyHeader, "checkout-header-1")
req = withCloudSubscriptionWorkspace(req, "owner")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionCheckout(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.req.Path != "/api/v1/subscriptions/checkout-sessions" || proxy.req.Method != http.MethodPost {
t.Fatalf("upstream = %s %s", proxy.req.Method, proxy.req.Path)
}
var body cloudSubscriptionCheckoutUpstreamRequest
if err := json.Unmarshal(proxy.req.Body, &body); err != nil {
t.Fatalf("decode upstream body: %v", err)
}
if body.WorkspaceID != testWorkspaceID {
t.Fatalf("upstream workspace_id = %q, want middleware workspace %q", body.WorkspaceID, testWorkspaceID)
}
if body.Interval != "year" || body.IdempotencyKey != "checkout-request-1" || body.CustomerEmail != handlerTestEmail {
t.Fatalf("upstream body = %+v", body)
}
if got := proxy.req.Headers.Get(idempotencyKeyHeader); got != "checkout-header-1" {
t.Fatalf("upstream idempotency key = %q", got)
}
}
func TestCreateCloudWorkspaceSubscriptionCheckoutFailsWhenPayerCannotBeResolved(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := newRequest(http.MethodPost, "/api/cloud-subscriptions/checkout-sessions", map[string]any{
"interval": "month",
"idempotency_key": "checkout-request-missing-payer",
})
req.Header.Set("X-User-ID", "00000000-0000-0000-0000-000000000099")
req = withCloudSubscriptionWorkspace(req, "owner")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionCheckout(w, req)
if w.Code != http.StatusInternalServerError || !strings.Contains(w.Body.String(), "failed to resolve checkout payer") {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called without an authoritative payer email")
}
}
func TestCreateCloudWorkspaceSubscriptionCheckoutRejectsInvalidPayerID(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := newRequest(http.MethodPost, "/api/cloud-subscriptions/checkout-sessions", map[string]any{
"interval": "month",
"idempotency_key": "checkout-request-invalid-payer",
})
req.Header.Set("X-User-ID", "not-a-uuid")
req = withCloudSubscriptionWorkspace(req, "owner")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionCheckout(w, req)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "invalid user id") {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called with an invalid payer id")
}
}
func TestCreateCloudWorkspaceSubscriptionCheckoutRejectsEmptyPayerEmail(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
emptyEmailUserID := dbfx.User(t, "Empty Checkout Email", " ")
dbfx.Member(t, testWorkspaceID, emptyEmailUserID, "owner")
req := newRequest(http.MethodPost, "/api/cloud-subscriptions/checkout-sessions", map[string]any{
"interval": "month",
"idempotency_key": "checkout-request-empty-email",
})
req.Header.Set("X-User-ID", emptyEmailUserID)
req = withCloudSubscriptionWorkspace(req, "owner")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionCheckout(w, req)
if w.Code != http.StatusInternalServerError || !strings.Contains(w.Body.String(), "checkout payer email is unavailable") {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called without a payer email")
}
}
func TestCreateCloudWorkspaceSubscriptionCheckoutAcceptsHeaderIdempotencyKey(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusCreated,
Body: []byte(`{"url":"https://checkout.stripe.test/session"}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(
newRequest(http.MethodPost, "/api/cloud-subscriptions/checkout-sessions", map[string]any{"interval": "month"}),
"admin",
)
req.Header.Set(idempotencyKeyHeader, "checkout-header-only")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionCheckout(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var body cloudSubscriptionCheckoutUpstreamRequest
if err := json.Unmarshal(proxy.req.Body, &body); err != nil {
t.Fatalf("decode upstream body: %v", err)
}
if body.IdempotencyKey != "checkout-header-only" {
t.Fatalf("upstream body idempotency_key = %q", body.IdempotencyKey)
}
}
func TestCloudWorkspaceSeatPurchaseForwardsOnlyAdditiveConfirmation(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true, resp: &cloudruntime.Response{StatusCode: http.StatusAccepted, Body: []byte(`{"status":"submitted"}`)}}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(http.MethodPost, "/api/cloud-subscriptions/seats/purchases", map[string]any{
"workspace_id": "00000000-0000-0000-0000-000000000001",
"target_seats": 999,
"additional_seats": 2,
"expected_current_seats": 5,
"expected_purchase_version": 9,
"accepted_proration_amount": 425,
"currency": "USD",
"idempotency_key": "seat-request-1",
}), "admin")
w := httptest.NewRecorder()
testHandler.PurchaseCloudWorkspaceSubscriptionSeats(w, req)
if w.Code != http.StatusAccepted || proxy.req.Path != "/api/v1/subscriptions/"+testWorkspaceID+"/seats/purchases" {
t.Fatalf("status=%d path=%s body=%s", w.Code, proxy.req.Path, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(proxy.req.Body, &body); err != nil {
t.Fatal(err)
}
if _, ok := body["workspace_id"]; ok {
t.Fatalf("client workspace leaked upstream: %v", body)
}
if _, ok := body["target_seats"]; ok {
t.Fatalf("absolute target leaked upstream: %v", body)
}
if body["additional_seats"] != float64(2) || body["currency"] != "usd" || body["idempotency_key"] != "seat-request-1" {
t.Fatalf("upstream body=%v", body)
}
if proxy.req.Headers.Get(idempotencyKeyHeader) != "seat-request-1" {
t.Fatalf("upstream idempotency=%q", proxy.req.Headers.Get(idempotencyKeyHeader))
}
}
func TestCloudWorkspaceSeatPurchasePreviewUsesAuthoritativePath(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true, resp: &cloudruntime.Response{StatusCode: http.StatusOK, Body: []byte(`{"resulting_seats":7}`)}}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(http.MethodPost, "/api/cloud-subscriptions/seats/purchase-preview", map[string]any{
"additional_seats": 10_001, "workspace_id": "00000000-0000-0000-0000-000000000002", "target_seats": 999,
}), "owner")
w := httptest.NewRecorder()
testHandler.PreviewCloudWorkspaceSubscriptionSeatPurchase(w, req)
if w.Code != http.StatusOK || proxy.req.Path != "/api/v1/subscriptions/"+testWorkspaceID+"/seats/purchase-preview" {
t.Fatalf("status=%d path=%s body=%s", w.Code, proxy.req.Path, w.Body.String())
}
if string(proxy.req.Body) != `{"additional_seats":10001}` {
t.Fatalf("upstream body=%s", proxy.req.Body)
}
}
func TestCloudWorkspaceSeatPurchaseRejectsInvalidRequests(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
tests := []struct {
name string
role string
body map[string]any
header string
}{
{name: "missing confirmation fields", role: "owner", body: map[string]any{"additional_seats": 1}},
{name: "non-positive current seats", role: "owner", body: map[string]any{"additional_seats": 2, "expected_current_seats": 0, "expected_purchase_version": 1, "accepted_proration_amount": 0, "currency": "usd", "idempotency_key": "key"}},
{name: "bad currency", role: "owner", body: map[string]any{"additional_seats": 1, "expected_current_seats": 5, "expected_purchase_version": 1, "accepted_proration_amount": 0, "currency": "u$d", "idempotency_key": "key"}},
{name: "long key", role: "owner", body: map[string]any{"additional_seats": 1, "expected_current_seats": 5, "expected_purchase_version": 1, "accepted_proration_amount": 0, "currency": "usd"}, header: strings.Repeat("a", 201)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(http.MethodPost, "/api/cloud-subscriptions/seats/purchases", tc.body), tc.role)
if tc.header != "" {
req.Header.Set(idempotencyKeyHeader, tc.header)
}
w := httptest.NewRecorder()
testHandler.PurchaseCloudWorkspaceSubscriptionSeats(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("invalid request reached upstream")
}
})
}
}
func TestCloudWorkspaceSubscriptionWritesRequireManagerRole(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(
newRequest(http.MethodPost, "/api/cloud-subscriptions/portal-sessions", nil),
"member",
)
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionPortal(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called for a non-manager member")
}
}
func TestCloudWorkspaceSubscriptionsRejectMachineActorsAtHandler(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
for _, actorSource := range []string{"task_token", "cloud_pat"} {
t.Run(actorSource, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(
newRequest(http.MethodPost, "/api/cloud-subscriptions/portal-sessions", nil),
"owner",
)
req.Header.Set("X-Actor-Source", actorSource)
req.Header.Set(idempotencyKeyHeader, "portal-request-1")
w := httptest.NewRecorder()
testHandler.CreateCloudWorkspaceSubscriptionPortal(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called for a machine actor")
}
})
}
}
func TestCloudWorkspaceSubscriptionMutationsValidateLocally(t *testing.T) {
withFeatureFlag(t, testHandler, featureflags.BillingWorkspaceSubscriptions, true)
cases := []struct {
name string
path string
body any
header string
wantError string
invoke func(http.ResponseWriter, *http.Request)
}{
{
name: "checkout missing idempotency key",
path: "/api/cloud-subscriptions/checkout-sessions",
body: map[string]any{"interval": "month"},
wantError: "Idempotency-Key or idempotency_key is required",
invoke: testHandler.CreateCloudWorkspaceSubscriptionCheckout,
},
{
name: "checkout invalid interval",
path: "/api/cloud-subscriptions/checkout-sessions",
body: map[string]any{"interval": "weekly", "idempotency_key": "checkout-request-1"},
wantError: "interval must be month or year",
invoke: testHandler.CreateCloudWorkspaceSubscriptionCheckout,
},
{
name: "portal missing idempotency key",
path: "/api/cloud-subscriptions/portal-sessions",
wantError: "Idempotency-Key or idempotency_key is required",
invoke: testHandler.CreateCloudWorkspaceSubscriptionPortal,
},
{
name: "portal oversized idempotency key",
path: "/api/cloud-subscriptions/portal-sessions",
header: strings.Repeat("a", maxCloudSubscriptionIdempotencyKeyLength+1),
wantError: "idempotency key must be at most 255 bytes",
invoke: testHandler.CreateCloudWorkspaceSubscriptionPortal,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := withCloudSubscriptionWorkspace(newRequest(http.MethodPost, tc.path, tc.body), "owner")
if tc.header != "" {
req.Header.Set(idempotencyKeyHeader, tc.header)
}
w := httptest.NewRecorder()
tc.invoke(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), tc.wantError) {
t.Fatalf("body = %s, want error containing %q", w.Body.String(), tc.wantError)
}
if proxy.called {
t.Fatal("upstream must not be called for a locally invalid request")
}
})
}
}
// --- Stripe webhook ---
// TestStripeWebhookForwardsRawBodyAndSignature is the critical
// invariant for the webhook proxy: bytes go upstream byte-for-byte,
// and the Stripe-Signature header rides along. Even one stray
// transformation breaks Stripe's HMAC verification on the cloud side
// and leaves topups stuck in `pending` forever.
//
// We deliberately use a body that includes leading whitespace, a
// trailing newline, and unusual key ordering to catch any
// json.Unmarshal/Marshal round-trip the JSON proxy might
// inadvertently apply.
func TestStripeWebhookForwardsRawBodyAndSignature(t *testing.T) {
rawBody := " \n{\"id\":\"evt_test\",\"type\":\"checkout.session.completed\"}\n"
const sig = "t=1700000000,v1=deadbeef0000aaaa"
const ct = "application/json; charset=utf-8"
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusOK,
Body: []byte(`{"received":true}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe", strings.NewReader(rawBody))
req.Header.Set("Stripe-Signature", sig)
req.Header.Set("Content-Type", ct)
// Deliberately NO X-User-ID — the webhook must work without auth.
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if !proxy.called {
t.Fatal("upstream proxy must be called")
}
if proxy.req.Method != http.MethodPost || proxy.req.Path != "/api/v1/webhooks/stripe" {
t.Fatalf("upstream %s %s", proxy.req.Method, proxy.req.Path)
}
if string(proxy.req.Body) != rawBody {
t.Fatalf("upstream body = %q, want %q (byte-perfect)", proxy.req.Body, rawBody)
}
if got := proxy.req.Headers.Get("Stripe-Signature"); got != sig {
t.Fatalf("upstream Stripe-Signature = %q, want %q", got, sig)
}
// Content-Type must arrive verbatim — preserving the
// `; charset=utf-8` suffix Stripe always sends. cloudruntime's
// default would otherwise strip it to plain `application/json`.
if got := proxy.req.Headers.Get("Content-Type"); got != ct {
t.Fatalf("upstream Content-Type = %q, want %q", got, ct)
}
if proxy.req.UserID != "" {
t.Errorf("upstream user_id should be empty for webhook, got %q", proxy.req.UserID)
}
if proxy.req.Op != "billing" {
t.Errorf("upstream op = %q, want billing", proxy.req.Op)
}
}
// TestStripeWebhookMissingSignatureRejectedLocally pins the early
// 401 we now return when Stripe-Signature is absent. Real Stripe
// deliveries ALWAYS include the header; absence ≡ not from Stripe.
// Rejecting locally saves the upstream RTT (and prevents using us
// as a DoS amplifier against the cloud Billing service).
//
// The contract is documented in HandleCloudBillingStripeWebhook:
// our local 401 is a strict superset of what the upstream would do
// in this case, so Stripe's delivery dashboard sees the same
// outcome it would have if the request had reached cloud.
func TestStripeWebhookMissingSignatureRejectedLocally(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe",
strings.NewReader(`{"id":"evt"}`))
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must NOT be called when Stripe-Signature is missing")
}
}
// TestStripeWebhookForwardsEmptyBody confirms we don't pre-reject an
// empty body — Stripe's webhook tester sometimes sends pings, and the
// upstream is the source of truth for what's an acceptable payload.
// (We do still cap large bodies; that's a separate test.) The
// signature header is set because, post-fix, the absence of it is
// itself a 401 — see TestStripeWebhookMissingSignatureRejectedLocally.
func TestStripeWebhookForwardsEmptyBody(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{
enabled: true,
resp: &cloudruntime.Response{
StatusCode: http.StatusBadRequest,
Body: []byte(`{"error":"empty body"}`),
},
}
useCloudRuntimeProxy(t, proxy)
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe", http.NoBody)
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if !proxy.called {
t.Fatal("upstream must be called even on empty body")
}
if len(proxy.req.Body) != 0 {
t.Errorf("upstream body = %q, want empty", proxy.req.Body)
}
}
// TestStripeWebhookRejectsLargeBody pins the body cap. Stripe's
// real payloads are well under 1 MiB; an attacker (or a misconfigured
// sender) flooding us with multi-MB bodies must be cut off before we
// buffer the whole thing in memory, and before we spend a Cloud
// upstream round-trip on a doomed verification.
func TestStripeWebhookRejectsLargeBody(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
body := bytes.NewReader(bytes.Repeat([]byte("a"), maxStripeWebhookBodySize+1))
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe", body)
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called for oversized webhook body")
}
}
// TestStripeWebhookDisabledReturnsForbidden mirrors the
// cloud-runtime disabled test but for the webhook path. Self-hosted
// deployments without a cloud URL must return a non-retryable 403, not crash.
func TestStripeWebhookDisabledReturnsForbidden(t *testing.T) {
useCloudRuntimeProxy(t, &fakeCloudRuntimeProxy{enabled: false})
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe",
strings.NewReader(`{"id":"evt"}`))
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
// TestStripeWebhookRateLimited pins the per-IP rate-limit fast
// path. With a denying limiter installed the handler must 429
// BEFORE consulting upstream. Mirrors HandleAutopilotWebhook's
// behaviour: the public webhook ingress sits behind the same
// WebhookIPRateLimiter so a flood of bogus requests doesn't burn
// cloud-side budget.
func TestStripeWebhookRateLimited(t *testing.T) {
proxy := &fakeCloudRuntimeProxy{enabled: true}
useCloudRuntimeProxy(t, proxy)
prevLimiter := testHandler.WebhookIPRateLimiter
testHandler.WebhookIPRateLimiter = denyingWebhookIPRateLimiter{}
t.Cleanup(func() { testHandler.WebhookIPRateLimiter = prevLimiter })
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe",
strings.NewReader(`{"id":"evt"}`))
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
req.RemoteAddr = "203.0.113.7:1234"
w := httptest.NewRecorder()
testHandler.HandleCloudBillingStripeWebhook(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
if proxy.called {
t.Fatal("upstream must not be called when rate limited")
}
}
// denyingWebhookIPRateLimiter is the smallest possible limiter that
// always says "no". It exists to drive the 429 branch without
// requiring a Redis test instance — the limiter interface is the
// same one HandleAutopilotWebhook uses.
type denyingWebhookIPRateLimiter struct{}
func (denyingWebhookIPRateLimiter) Allow(_ context.Context, _ string) bool { return false }