Files
pad/internal/billing/cloud_client_test.go
T
xarmian 8e067c19db feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)

New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').

Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
  place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
  /admin/metrics/billing with the X-Cloud-Secret header (the same secret
  pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
  package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
  the account-delete tests still satisfy the extended interface.

Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
  to render: cloud_unreachable=true (sidecar errored or unwired) and
  stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
  non-admin gets 403.

Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.

Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
  X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
  non-200 → SidecarError, transport error stays bare, malformed JSON,
  nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
  (merges local + remote correctly, handles plan="" → "free", filters
  new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
  degrades to local-only, transport error degrades, sidecar 5xx degrades,
  stripe_configured=false propagates verbatim with cloud_unreachable=false.

Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).

* fix(admin): address Codex review (round 1) on billing-stats proxy

- Replace handler-side ListUsers walk with store.CountBillingAggregates
  (two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
  for new pro signups). Removes the per-row TOTP decrypt overhead that
  ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
  healthy" requires cloud_unreachable=false AND stripe_configured=true,
  not "both flags false" as previously stated.

Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.

* fix(store): GROUP BY normalised plan expression in CountBillingAggregates

Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.

Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
2026-04-27 14:42:53 -04:00

410 lines
12 KiB
Go

package billing
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newStub starts a test HTTP server and returns a CloudClient pointed at it.
// Callers supply the handler to control the response.
func newStub(t *testing.T, h http.HandlerFunc) (*CloudClient, *httptest.Server) {
t.Helper()
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
return NewCloudClient(ts.URL, "test-secret"), ts
}
func TestGetBillingMetrics_HappyPath(t *testing.T) {
var (
gotMethod string
gotPath string
gotSecret string
gotAccept string
)
want := BillingMetricsResponse{
StripeConfigured: true,
ActiveSubscriptions: 7,
MRRCents: 49000,
ARRCents: 588000,
Currency: "usd",
ChurnRate30d: 0.05,
Cancelled30d: 2,
ComputedAt: time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC),
CacheAgeSeconds: 12,
}
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotSecret = r.Header.Get("X-Cloud-Secret")
gotAccept = r.Header.Get("Accept")
w.WriteHeader(http.StatusOK)
body, _ := json.Marshal(want)
_, _ = w.Write(body)
})
got, err := client.GetBillingMetrics()
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if gotMethod != http.MethodGet {
t.Errorf("method: want GET, got %s", gotMethod)
}
if gotPath != "/admin/metrics/billing" {
t.Errorf("path: want /admin/metrics/billing, got %s", gotPath)
}
if gotSecret != "test-secret" {
t.Errorf("X-Cloud-Secret: want test-secret, got %s", gotSecret)
}
if gotAccept != "application/json" {
t.Errorf("Accept: want application/json, got %s", gotAccept)
}
if got.ActiveSubscriptions != want.ActiveSubscriptions ||
got.MRRCents != want.MRRCents ||
got.ARRCents != want.ARRCents ||
got.Currency != want.Currency ||
got.ChurnRate30d != want.ChurnRate30d ||
got.Cancelled30d != want.Cancelled30d ||
got.CacheAgeSeconds != want.CacheAgeSeconds ||
!got.ComputedAt.Equal(want.ComputedAt) {
t.Errorf("decoded response mismatch:\nwant %+v\ngot %+v", want, *got)
}
}
func TestGetBillingMetrics_StripeNotConfigured(t *testing.T) {
// pad-cloud returns 200 + stripe_configured=false when STRIPE_SECRET_KEY
// is unset. The client must treat this as a successful call (no error).
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"stripe_configured":false,"currency":"usd","computed_at":"2026-04-27T18:00:00Z"}`))
})
got, err := client.GetBillingMetrics()
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if got.StripeConfigured {
t.Errorf("stripe_configured: want false, got true")
}
}
func TestGetBillingMetrics_NonOK_ReturnsSidecarError(t *testing.T) {
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"Forbidden"}`))
})
_, err := client.GetBillingMetrics()
var sidecarErr *SidecarError
if !errors.As(err, &sidecarErr) {
t.Fatalf("want *SidecarError, got %T: %v", err, err)
}
if sidecarErr.Status != http.StatusForbidden {
t.Errorf("status: want 403, got %d", sidecarErr.Status)
}
if !strings.Contains(sidecarErr.Body, "Forbidden") {
t.Errorf("body: want contains 'Forbidden', got %q", sidecarErr.Body)
}
}
func TestGetBillingMetrics_ServerError(t *testing.T) {
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
})
_, err := client.GetBillingMetrics()
var sidecarErr *SidecarError
if !errors.As(err, &sidecarErr) || sidecarErr.Status != 500 {
t.Errorf("want *SidecarError status=500, got %v", err)
}
}
func TestGetBillingMetrics_TransportError(t *testing.T) {
// Closed server triggers a transport-level error from c.http.Do.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
client := NewCloudClient(srv.URL, "test-secret")
srv.Close()
_, err := client.GetBillingMetrics()
if err == nil {
t.Fatal("want transport error, got nil")
}
// Transport errors should NOT be wrapped as *SidecarError — handler
// callers branch on type to log them differently.
var sidecarErr *SidecarError
if errors.As(err, &sidecarErr) {
t.Errorf("transport error should not be SidecarError, got %v", err)
}
}
func TestGetBillingMetrics_NilClient(t *testing.T) {
var c *CloudClient
if _, err := c.GetBillingMetrics(); err == nil {
t.Error("nil client: want error, got nil")
}
}
func TestGetBillingMetrics_UnconfiguredClient(t *testing.T) {
c := &CloudClient{} // empty baseURL + cloudSecret
if _, err := c.GetBillingMetrics(); err == nil {
t.Error("unconfigured client: want error, got nil")
}
}
func TestGetBillingMetrics_MalformedJSON(t *testing.T) {
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`not json`))
})
if _, err := client.GetBillingMetrics(); err == nil {
t.Error("malformed JSON: want error, got nil")
}
}
func TestCancelCustomer_HappyPath_SendsCorrectRequest(t *testing.T) {
var (
gotMethod string
gotPath string
gotContentType string
gotBody map[string]string
)
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotContentType = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true,"subscriptions_cancelled":2}`))
})
if err := client.CancelCustomer("cus_abc"); err != nil {
t.Fatalf("expected nil error on 200, got %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("expected POST, got %s", gotMethod)
}
if gotPath != "/billing/cancel-customer" {
t.Errorf("expected /billing/cancel-customer, got %s", gotPath)
}
if gotContentType != "application/json" {
t.Errorf("expected application/json content type, got %s", gotContentType)
}
if gotBody["customer_id"] != "cus_abc" {
t.Errorf("expected customer_id=cus_abc, got %q", gotBody["customer_id"])
}
if gotBody["cloud_secret"] != "test-secret" {
t.Errorf("expected cloud_secret=test-secret, got %q", gotBody["cloud_secret"])
}
}
// Each real pad-cloud failure status maps to a SidecarError carrying that
// status. Together these cover every non-2xx shape pad-cloud returns:
// - 400 on malformed request or non-cus_ customer_id
// - 403 on cloud_secret mismatch
// - 500 on internal / Stripe failure
// - 503 when Stripe is not configured
//
// All of them must produce a SidecarError — the handler treats every one
// as "abort the delete", regardless of bucket.
func TestCancelCustomer_NonOK_ReturnsSidecarError(t *testing.T) {
cases := []struct {
name string
status int
body string
bodyHas string
}{
{"400_bad_request", http.StatusBadRequest, `{"error":"customer_id must start with 'cus_'"}`, "customer_id must start with"},
{"403_wrong_secret", http.StatusForbidden, `{"error":"Forbidden"}`, "Forbidden"},
{"500_stripe_failure", http.StatusInternalServerError, `{"error":"Failed to cancel subscription"}`, "Failed to cancel"},
{"503_not_configured", http.StatusServiceUnavailable, `{"error":"Stripe billing not configured"}`, "not configured"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.status)
_, _ = w.Write([]byte(tc.body))
})
err := client.CancelCustomer("cus_abc")
if err == nil {
t.Fatalf("expected error on %d, got nil", tc.status)
}
var se *SidecarError
if !errors.As(err, &se) {
t.Fatalf("expected *SidecarError, got %T: %v", err, err)
}
if se.Status != tc.status {
t.Errorf("expected status %d, got %d", tc.status, se.Status)
}
if !strings.Contains(se.Body, tc.bodyHas) {
t.Errorf("expected body to include %q, got %q", tc.bodyHas, se.Body)
}
})
}
}
func TestCancelCustomer_TransportFailure_NotSidecarError(t *testing.T) {
// Point at a URL nothing's listening on; use a short client timeout so
// the test doesn't hang if the OS accepts the connect.
client := NewCloudClient("http://127.0.0.1:1", "test-secret")
client.http.Timeout = 500 * time.Millisecond
err := client.CancelCustomer("cus_abc")
if err == nil {
t.Fatal("expected transport error, got nil")
}
// A transport failure must NOT be a *SidecarError — callers use this to
// distinguish "retry" (no status from upstream) from "upstream spoke".
var se *SidecarError
if errors.As(err, &se) {
t.Errorf("transport failure must not be SidecarError, got %v", err)
}
}
func TestCancelCustomer_EmptyCustomerID_ReturnsError(t *testing.T) {
called := false
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
})
err := client.CancelCustomer("")
if err == nil {
t.Fatal("expected error for empty customer_id, got nil")
}
if called {
t.Error("expected no HTTP call for empty customer_id")
}
}
func TestCancelCustomer_NilClient_ReturnsError(t *testing.T) {
var c *CloudClient
if err := c.CancelCustomer("cus_abc"); err == nil {
t.Fatal("expected error from nil receiver, got nil")
}
}
func TestCancelCustomer_UnconfiguredClient_ReturnsError(t *testing.T) {
c := NewCloudClient("", "")
err := c.CancelCustomer("cus_abc")
if err == nil {
t.Fatal("expected error for unconfigured client, got nil")
}
if strings.Contains(err.Error(), "dial") {
t.Errorf("expected config error, got transport error: %v", err)
}
}
func TestResolveOutboundSecret(t *testing.T) {
cases := []struct {
name string
explicit string
inboundList string
want string
wantEmpty bool
}{
{
name: "explicit wins over inbound",
explicit: "explicit-key",
inboundList: "new,old",
want: "explicit-key",
},
{
name: "explicit with whitespace is trimmed",
explicit: " pinned-key ",
inboundList: "new,old",
want: "pinned-key",
},
{
name: "falls back to last inbound during rotation (new,old)",
explicit: "",
inboundList: "new-key,old-key",
want: "old-key",
},
{
name: "single inbound value is used",
explicit: "",
inboundList: "only-key",
want: "only-key",
},
{
name: "skips trailing empty entries",
explicit: "",
inboundList: "new-key,old-key,,",
want: "old-key",
},
{
name: "skips whitespace-only entries",
explicit: "",
inboundList: "new-key, ,old-key",
want: "old-key",
},
{
name: "explicit set but inbound empty → explicit",
explicit: "only-explicit",
inboundList: "",
want: "only-explicit",
},
{
name: "both empty → empty result (caller should fail startup)",
explicit: "",
inboundList: "",
wantEmpty: true,
},
{
name: "both whitespace → empty result",
explicit: " ",
inboundList: ", , ,",
wantEmpty: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ResolveOutboundSecret(tc.explicit, tc.inboundList)
if tc.wantEmpty {
if got != "" {
t.Errorf("expected empty, got %q", got)
}
return
}
if got != tc.want {
t.Errorf("ResolveOutboundSecret(%q, %q) = %q, want %q",
tc.explicit, tc.inboundList, got, tc.want)
}
})
}
}
func TestCancelCustomer_LargeResponseBody_DoesNotReadPastCap(t *testing.T) {
// A broken sidecar or misrouted proxy can stream us an enormous payload.
// We cap the read at maxResponseBody so we never allocate MBs of error
// body. Build a body bigger than the cap and verify the client still
// returns a SidecarError with a truncated-but-bounded Body.
big := strings.Repeat("X", maxResponseBody*2)
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(big))
})
err := client.CancelCustomer("cus_abc")
var se *SidecarError
if !errors.As(err, &se) {
t.Fatalf("expected SidecarError, got %T", err)
}
if len(se.Body) > maxResponseBody {
t.Errorf("body was %d bytes, expected <= %d (cap)", len(se.Body), maxResponseBody)
}
}