mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 09:26:50 +08:00
* 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]>
907 lines
34 KiB
Go
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 }
|