Files
pad/internal/server/task3564_billing_users_test.go
T
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00

85 lines
2.8 KiB
Go

package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3564: GET /admin/billing-users pages every user the reconcile sweep
// must re-sync (a stored Stripe customer, or a stripe-sourced plan), and only
// those, behind the same cloud-secret rule as its siblings.
func TestBillingUsersPagesTheUsersTheSweepNeeds(t *testing.T) {
srv := testServer(t)
bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
srv.SetCloudMode(cloudContractSecret)
mk := func(email string) string {
u, err := srv.store.CreateUser(models.UserCreate{Email: email, Name: "U", Password: "pw-billing-x"})
if err != nil {
t.Fatal(err)
}
return u.ID
}
linked := mk("linked@example.com")
if err := srv.store.SetUserStripeCustomerID(linked, "cus_linked"); err != nil {
t.Fatal(err)
}
stripeSourced := mk("sourced@example.com")
if _, err := srv.store.DB().Exec(`UPDATE users SET plan = 'free', plan_source = 'stripe' WHERE id = ?`, stripeSourced); err != nil {
t.Fatal(err)
}
mk("nobody@example.com") // no link, manual plan: not a billing user
get := func(path string, hdr map[string]string) (*httptest.ResponseRecorder, struct {
Users []map[string]any `json:"users"`
Next string `json:"next"`
}) {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, cloudAdminReq(t, "GET", path, nil, hdr))
var out struct {
Users []map[string]any `json:"users"`
Next string `json:"next"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr, out
}
hdr := map[string]string{"X-Cloud-Secret": cloudContractSecret}
// Paged one at a time, the cursor walks exactly the two billing users.
var seen []string
after := ""
for i := 0; i < 5; i++ {
rr, page := get("/api/v1/admin/billing-users?limit=1&after="+after, hdr)
if rr.Code != http.StatusOK {
t.Fatalf("status %d: %s", rr.Code, rr.Body.String())
}
for _, u := range page.Users {
seen = append(seen, u["user_id"].(string))
}
if page.Next == "" {
break
}
after = page.Next
}
want := map[string]bool{linked: true, stripeSourced: true}
if len(seen) != 2 || !want[seen[0]] || !want[seen[1]] {
t.Fatalf("pages walked %v, want exactly the linked and the stripe-sourced user", seen)
}
// Without the secret (and without an admin session) it is refused.
if rr, _ := get("/api/v1/admin/billing-users", nil); rr.Code == http.StatusOK {
t.Fatalf("no credential answered %d", rr.Code)
}
if rr, _ := get("/api/v1/admin/billing-users", map[string]string{"X-Cloud-Secret": "wrong"}); rr.Code == http.StatusOK {
t.Fatalf("a wrong secret answered %d", rr.Code)
}
// A bad limit is a 400.
if rr, _ := get("/api/v1/admin/billing-users?limit=0", hdr); rr.Code != http.StatusBadRequest {
t.Fatalf("limit=0 answered %d, want 400", rr.Code)
}
}