package main import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) // fakeStripe stands in for api.stripe.com: it captures the checkout-session // form and answers with a hosted-page URL, so the cloud server exercises its // real HTTP path. func fakeStripe(t *testing.T) (*httptest.Server, *url.Values) { t.Helper() captured := &url.Values{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/checkout/sessions" { w.WriteHeader(http.StatusNotFound) return } if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) return } *captured = r.PostForm writeJSON(w, http.StatusOK, map[string]any{ "id": "cs_test_123", "url": "https://checkout.stripe.com/c/pay/cs_test_123", }) })) t.Cleanup(srv.Close) return srv, captured } // billingTestCloud is newTestCloud plus a configured stripeBilling pointed at // the fake. func billingTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values) { t.Helper() stripe, captured := fakeStripe(t) srv, s := newTestCloud(t, okControl(t)) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test_secret", webhookSecret: "whsec_test_secret", tiers: []billingTier{ {Key: "family", Name: "Family", Label: "$5/year", Price: "price_family_y"}, {Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"}, {Key: "business", Name: "Business", Label: "$99/month", Price: "price_business_m"}, }, client: http.DefaultClient, } return srv, s, captured } // postCheckout starts a checkout for host as (token, requester), buying tier. func postCheckout(t *testing.T, srv *httptest.Server, token, host, requester, tier string) *http.Response { t.Helper() body, _ := json.Marshal(map[string]string{ "requester": requester, "tier": tier, "success_url": "https://home.example.com/?upgraded=" + host, "cancel_url": "https://home.example.com/", }) req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body)) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // stripeSign builds a valid Stripe-Signature header for payload at time now. func stripeSign(payload []byte, secret string, now time.Time) string { ts := fmt.Sprintf("%d", now.Unix()) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(payload) return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil)) } // postWebhook delivers an event payload with the given signature header. func postWebhook(t *testing.T, srv *httptest.Server, payload []byte, sig string) *http.Response { t.Helper() req, _ := http.NewRequest("POST", srv.URL+"/api/stripe/webhook", bytes.NewReader(payload)) req.Header.Set("Stripe-Signature", sig) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func TestCheckoutMintsSession(t *testing.T) { srv, _, captured := billingTestCloud(t) tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) resp := postCheckout(t, srv, tok, host, "user-1", "friends") if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "checkout.stripe.com") { t.Errorf("url = %q, want the hosted checkout page", u) } // The session carried the space's ledger id (id 1 — fresh DB) in both // routing slots, the chosen tier's price + key, and NO customer details. f := *captured if f.Get("mode") != "subscription" || f.Get("line_items[0][price]") != "price_friends_m" { t.Errorf("form = %v", f) } if f.Get("client_reference_id") != "1" || f.Get("subscription_data[metadata][space_id]") != "1" { t.Errorf("space id routing: client_reference_id=%q metadata=%q, want 1/1", f.Get("client_reference_id"), f.Get("subscription_data[metadata][space_id]")) } if f.Get("metadata[tier]") != "friends" || f.Get("subscription_data[metadata][tier]") != "friends" { t.Errorf("tier routing: session=%q subscription=%q, want friends/friends", f.Get("metadata[tier]"), f.Get("subscription_data[metadata][tier]")) } if !strings.Contains(f.Get("success_url"), "upgraded="+host) { t.Errorf("success_url = %q", f.Get("success_url")) } if f.Get("allow_promotion_codes") != "true" { t.Errorf("allow_promotion_codes = %q, want true (dashboard promo codes must be redeemable)", f.Get("allow_promotion_codes")) } for k := range f { if strings.Contains(k, "customer") || strings.Contains(k, "email") { t.Errorf("checkout form leaks a customer field: %s", k) } } } func TestCheckoutGates(t *testing.T) { srv, s, _ := billingTestCloud(t) tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) // No caller token → 401. if r := postCheckout(t, srv, "", host, "user-1", "friends"); r.StatusCode != http.StatusUnauthorized { t.Errorf("no token: %d, want 401", r.StatusCode) } // Ownership is the gate: someone else's requester id → 404. if r := postCheckout(t, srv, tok, host, "someone-else", "friends"); r.StatusCode != http.StatusNotFound { t.Errorf("wrong requester: %d, want 404", r.StatusCode) } // Unknown tier → 400. if r := postCheckout(t, srv, tok, host, "user-1", "platinum"); r.StatusCode != http.StatusBadRequest { t.Errorf("unknown tier: %d, want 400", r.StatusCode) } // Missing redirect URLs → 400. req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", strings.NewReader(`{"requester":"user-1","tier":"friends"}`)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("no urls: %d, want 400", resp.StatusCode) } // Already upgraded → 409. label, _, _ := strings.Cut(host, ".") id, _ := spaceID(label) if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { t.Fatal(err) } if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusConflict { t.Errorf("already paid: %d, want 409", r.StatusCode) } // Billing off → 501 (and the webhook too). s.billing = nil if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusNotImplemented { t.Errorf("billing off: %d, want 501", r.StatusCode) } if r := postWebhook(t, srv, []byte("{}"), "t=1,v1=00"); r.StatusCode != http.StatusNotImplemented { t.Errorf("webhook, billing off: %d, want 501", r.StatusCode) } } func TestWebhookRejectsBadSignatures(t *testing.T) { srv, _, _ := billingTestCloud(t) payload := []byte(`{"type":"checkout.session.completed","data":{"object":{}}}`) // No header / garbage header / wrong secret / stale timestamp → all 400. if r := postWebhook(t, srv, payload, ""); r.StatusCode != http.StatusBadRequest { t.Errorf("no header: %d, want 400", r.StatusCode) } if r := postWebhook(t, srv, payload, "not-a-header"); r.StatusCode != http.StatusBadRequest { t.Errorf("garbage header: %d, want 400", r.StatusCode) } if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_WRONG", time.Now())); r.StatusCode != http.StatusBadRequest { t.Errorf("wrong secret: %d, want 400", r.StatusCode) } stale := stripeSign(payload, "whsec_test_secret", time.Now().Add(-time.Hour)) if r := postWebhook(t, srv, payload, stale); r.StatusCode != http.StatusBadRequest { t.Errorf("stale timestamp: %d, want 400 (replay window)", r.StatusCode) } // And the real thing → 200. if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { t.Errorf("valid signature: %d, want 200", r.StatusCode) } } func TestWebhookEntitlementLifecycle(t *testing.T) { srv, s, _ := billingTestCloud(t) tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) deliver := func(event string) { t.Helper() payload := []byte(event) if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { t.Fatalf("webhook: %d, want 200", r.StatusCode) } } entitlement := func() Space { t.Helper() spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1") if err != nil || len(spaces) != 1 { t.Fatalf("listSpaces: %v (%d rows)", err, len(spaces)) } return spaces[0] } if got := entitlement(); got.Entitlement != entFree { t.Fatalf("fresh space entitlement = %q, want free", got.Entitlement) } // Checkout completes → paid, ids + tier recorded. deliver(`{"type":"checkout.session.completed","data":{"object":{ "mode":"subscription","client_reference_id":"1","customer":"cus_9","subscription":"sub_9", "metadata":{"tier":"family"}}}}`) if got := entitlement(); got.Entitlement != entPaid || got.Tier != "family" { t.Fatalf("after checkout: %+v, want paid/family", got) } // A subscription update (routed by metadata) refreshes the period end — // item-level current_period_end (2025+ API shape). deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"active","metadata":{"space_id":"1"}, "items":{"data":[{"current_period_end":1900000000}]}}}}`) if got := entitlement(); got.Entitlement != entPaid || got.PaidUntil != 1900000000 { t.Fatalf("after update: %+v, want paid until 1900000000", got) } // An update WITHOUT metadata still routes — by the stored subscription id — // and a top-level current_period_end (pre-2025 shape) works too. deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"active","current_period_end":1900000099}}}`) if got := entitlement(); got.PaidUntil != 1900000099 { t.Fatalf("fallback routing: paid_until = %d, want 1900000099", got.PaidUntil) } // A Billing Portal plan switch arrives as an update with a NEW price and // the ORIGINAL metadata — the price wins: tier follows it. deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"active","metadata":{"space_id":"1","tier":"family"}, "items":{"data":[{"current_period_end":1900000200,"price":{"id":"price_business_m"}}]}}}}`) if got := entitlement(); got.Tier != "business" || got.PaidUntil != 1900000200 { t.Fatalf("after plan switch: %+v, want tier business", got) } // The payer cancels (portal, at period end): still paid, but the listing // says the subscription runs out — and resuming clears it. deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"active","cancel_at_period_end":true,"metadata":{"space_id":"1"}, "current_period_end":1900000300}}}`) if got := entitlement(); got.Entitlement != entPaid || !got.CancelAtPeriodEnd || got.PaidUntil != 1900000300 { t.Fatalf("after cancel: %+v, want paid + cancelling at 1900000300", got) } deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"active","cancel_at_period_end":false,"metadata":{"space_id":"1"}, "current_period_end":1900000300}}}`) if got := entitlement(); got.CancelAtPeriodEnd { t.Fatalf("after resume: %+v, want cancelling cleared", got) } // Dunning keeps the space paid; a dead subscription drops it to free. deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_9","status":"past_due","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) if got := entitlement(); got.Entitlement != entPaid { t.Fatalf("past_due should keep access, got %q", got.Entitlement) } deliver(`{"type":"customer.subscription.deleted","data":{"object":{ "id":"sub_9","status":"canceled","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) if got := entitlement(); got.Entitlement != entFree { t.Fatalf("after deletion: entitlement = %q, want free", got.Entitlement) } // Events for spaces that don't exist are acknowledged, never retried. deliver(`{"type":"checkout.session.completed","data":{"object":{ "mode":"subscription","client_reference_id":"999","customer":"c","subscription":"s"}}}`) deliver(`{"type":"customer.subscription.updated","data":{"object":{ "id":"sub_unknown","status":"active"}}}`) // Unknown event types too. deliver(`{"type":"invoice.paid","data":{"object":{}}}`) _ = host // exercised above via createSpace } func TestVerifyStripeSignature(t *testing.T) { payload := []byte(`{"hello":"world"}`) secret := "whsec_abc" now := time.Unix(1_900_000_000, 0) good := stripeSign(payload, secret, now) if !verifyStripeSignature(payload, good, secret, now) { t.Error("valid signature rejected") } // Tolerance: inside ±5m passes, outside fails (either direction). if !verifyStripeSignature(payload, good, secret, now.Add(4*time.Minute)) { t.Error("signature inside the window rejected") } if verifyStripeSignature(payload, good, secret, now.Add(6*time.Minute)) { t.Error("stale signature accepted (replay window)") } if verifyStripeSignature(payload, good, secret, now.Add(-6*time.Minute)) { t.Error("future-dated signature accepted") } // Tampered payload / wrong secret / v0-only fail. if verifyStripeSignature([]byte(`{"hello":"tampered"}`), good, secret, now) { t.Error("tampered payload accepted") } if verifyStripeSignature(payload, good, "whsec_other", now) { t.Error("wrong secret accepted") } v0 := strings.Replace(good, "v1=", "v0=", 1) if verifyStripeSignature(payload, v0, secret, now) { t.Error("v0-only header accepted") } // An extra bogus v1 alongside the real one still passes (Stripe sends // multiples during secret rolls). multi := good + ",v1=" + strings.Repeat("00", 32) if !verifyStripeSignature(payload, multi, secret, now) { t.Error("multiple v1 signatures: the valid one should win") } } // TestReaperNeverSeesPaidSpaces pins the strongest billing invariant: a paid // space is not even a reap CANDIDATE, no matter how old or empty. func TestReaperNeverSeesPaidSpaces(t *testing.T) { db := openTestCloudDB(t) now := time.Unix(2_000_000_000, 0) old := now.Add(-20 * 24 * time.Hour).Unix() paidID := insertSpaceRow(t, db, "paid.11ch.at", old) freeID := insertSpaceRow(t, db, "free.11ch.at", old) if err := db.markSpacePaid(paidID, "cus_1", "sub_1", "friends", ""); err != nil { t.Fatal(err) } cands, err := db.spacesOlderThan(now.Unix()) if err != nil { t.Fatal(err) } if len(cands) != 1 || cands[0].ID != freeID { t.Fatalf("candidates = %+v, want only the free space (id %d)", cands, freeID) } // And end-to-end: a sweep deletes the free one, never the paid one — even // though both are old, never set up, single-member, contentless. deleted := map[string]bool{} sock := reapControl(t, map[string]int{"paid.11ch.at": 1, "free.11ch.at": 1}, nil, nil, deleted, nil) s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour} if reaped, _ := s.reapOnce(now); reaped != 1 { t.Fatalf("reaped = %d, want 1", reaped) } if deleted["paid.11ch.at"] { t.Error("a paid space must NEVER be reaped") } if !deleted["free.11ch.at"] { t.Error("the free never-claimed space should have been reaped") } } // TestBillingMigrationOverOldDB proves the billing columns land additively on a // pre-billing spaces table (the deployed cloud.db shape). func TestBillingMigrationOverOldDB(t *testing.T) { db := openTestCloudDB(t) // migrated schema // Simulate re-open over existing data (migrations must be no-ops then). id := insertSpaceRow(t, db, "existing.11ch.at", 100) if err := db.migrate(); err != nil { t.Fatalf("re-migrate: %v", err) } ent, err := db.spaceEntitlement(id) if err != nil || ent != entFree { t.Fatalf("existing row entitlement = %q (%v), want free", ent, err) } } func TestNewStripeBillingConfig(t *testing.T) { prices := `[{"key":"friends","name":"Friends","label":"$4/month","price":"price_x"}]` if b, err := newStripeBilling("", "", "", "https://api.stripe.com"); b != nil || err != nil { t.Errorf("all-empty: (%v, %v), want (nil, nil) — billing off", b, err) } if _, err := newStripeBilling("sk_x", "", "", "https://api.stripe.com"); err == nil { t.Error("partial config must error, not half-work") } if b, err := newStripeBilling("sk_x", "whsec_x", prices, "https://api.stripe.com/"); err != nil || b == nil || b.apiBase != "https://api.stripe.com" || len(b.tiers) != 1 { t.Errorf("full config: (%+v, %v)", b, err) } // Malformed price books refuse to start: bad JSON, empty, missing fields, dup keys. for _, bad := range []string{ "not-json", "[]", `[{"key":"a","name":"A","label":"$1"}]`, `[{"key":"a","name":"A","label":"$1","price":"p1"},{"key":"a","name":"B","label":"$2","price":"p2"}]`, } { if _, err := newStripeBilling("sk_x", "whsec_x", bad, "https://api.stripe.com"); err == nil { t.Errorf("prices %q accepted, want error", bad) } } } // TestQuotaExemptsPaidSpaces pins the point of the tier system: paid spaces // don't count toward the creation quota, so upgrading one frees a slot. func TestQuotaExemptsPaidSpaces(t *testing.T) { srv, s, _ := billingTestCloud(t) tok := mintCaller(t, srv, "home") // Fill the quota with free spaces. var firstHost string for i := 0; i < s.quota; i++ { resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("create %d: status %d", i, resp.StatusCode) } if i == 0 { firstHost, _ = decodeBody(t, resp)["host"].(string) } } if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict { t.Fatalf("over quota: status = %d, want 409", resp.StatusCode) } // Upgrade the first space → its slot frees → creation works again. label, _, _ := strings.Cut(firstHost, ".") id, _ := spaceID(label) if err := s.db.markSpacePaid(id, "cus_1", "sub_1", "friends", ""); err != nil { t.Fatal(err) } if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { t.Fatalf("after upgrade: status = %d, want 201 (paid space freed a slot)", resp.StatusCode) } // And the limit still holds for the free ones. if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict { t.Fatalf("refilled quota: status = %d, want 409", resp.StatusCode) } } // TestCreateBudgetCircuitBreaker pins the fleet-wide throttle: past the // trailing-24h budget, creation answers the capacity message — and deleting // spaces doesn't reset it (the log is append-only). func TestCreateBudgetCircuitBreaker(t *testing.T) { srv, s, _ := billingTestCloud(t) s.createBudget = 2 tok := mintCaller(t, srv, "home") var firstHost string for i := 0; i < 2; i++ { resp := createSpace(t, srv, tok, "user-1") if resp.StatusCode != http.StatusCreated { t.Fatalf("create %d: %d", i, resp.StatusCode) } if i == 0 { firstHost, _ = decodeBody(t, resp)["host"].(string) } } resp := createSpace(t, srv, tok, "user-2") // a DIFFERENT account — budget is global if resp.StatusCode != http.StatusServiceUnavailable { t.Fatalf("over budget: %d, want 503", resp.StatusCode) } if m := decodeBody(t, resp); m["error"] != "at_capacity" || m["message"] != spaceUnavailableMsg { t.Errorf("over budget body = %v", m) } // Deleting a space frees its quota slot but NOT the creation budget: // the log is append-only, so create-and-delete can't reset the throttle. label, _, _ := strings.Cut(firstHost, ".") id, _ := spaceID(label) if err := s.db.deleteSpaceByID(id); err != nil { t.Fatal(err) } if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusServiceUnavailable { t.Errorf("post-delete create: %d, want 503 (budget must not reset)", r.StatusCode) } } // TestPortalSession pins the manage path: only an upgraded space's owner gets // a portal link, minted for that space's own Stripe customer. func TestPortalSession(t *testing.T) { stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/billing_portal/sessions" { w.WriteHeader(http.StatusNotFound) return } r.ParseForm() if r.PostForm.Get("customer") != "cus_9" || r.PostForm.Get("return_url") == "" { t.Errorf("portal form = %v", r.PostForm) } writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"}) })) t.Cleanup(stripe.Close) srv, s, _ := billingTestCloud(t) s.billing.apiBase = stripe.URL tok := mintCaller(t, srv, "home") host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) post := func(requester string) *http.Response { body, _ := json.Marshal(map[string]string{"requester": requester, "return_url": "https://home.example.com/"}) req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // Not upgraded yet → 409 not_subscribed; wrong owner → 404. if r := post("user-1"); r.StatusCode != http.StatusConflict { t.Fatalf("unsubscribed portal: %d, want 409", r.StatusCode) } if r := post("someone-else"); r.StatusCode != http.StatusNotFound { t.Fatalf("wrong owner: %d, want 404", r.StatusCode) } // Upgrade it, then the portal link mints against its customer. label, _, _ := strings.Cut(host, ".") id, _ := spaceID(label) if err := s.db.markSpacePaid(id, "cus_9", "sub_9", "family", ""); err != nil { t.Fatal(err) } resp := post("user-1") if resp.StatusCode != http.StatusOK { t.Fatalf("portal: %d, want 200", resp.StatusCode) } if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "billing.stripe.com") { t.Errorf("url = %q", u) } } // ---- pay-before-create ----------------------------------------------------------- // payFirstRig: a cloud with billing pointed at a Stripe stub that mints // checkout sessions (capturing the form) and serves one retrievable session // (mutable — tests shape it per step), plus a control that records the last // create and can be told to fail n times. func payFirstRig(t *testing.T) (srv *httptest.Server, s *cloudServer, form *url.Values, sess *map[string]any, lastCreate *controlCreateReq, ctlFails *int) { t.Helper() form = &url.Values{} sess = &map[string]any{} stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "POST" && r.URL.Path == "/v1/checkout/sessions": r.ParseForm() *form = r.PostForm writeJSON(w, http.StatusOK, map[string]any{ "id": "cs_pf_1", "url": "https://checkout.stripe.com/c/pay/cs_pf_1", }) case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/"): writeJSON(w, http.StatusOK, *sess) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(stripe.Close) lastCreate = &controlCreateReq{} ctlFails = new(int) ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) *lastCreate = req if *ctlFails > 0 { *ctlFails-- writeControlErr(w, http.StatusInternalServerError, "create_failed", "boom") return } writeJSON(w, http.StatusCreated, map[string]any{ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#PF", }) }) srv, s = newTestCloud(t, ctl) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50/month", Price: "price_family_m"}}, client: http.DefaultClient, } return } // postCloud posts a JSON body to a caller-token-gated cloud endpoint. func postCloud(t *testing.T, srv *httptest.Server, tok, path string, body map[string]string) *http.Response { t.Helper() payload, _ := json.Marshal(body) req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+tok) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // paidSessionFor shapes the stub's retrievable session as a completed pay-first // checkout for ledger row id. func paidSessionFor(id int64) map[string]any { return map[string]any{ "id": "cs_pf_1", "mode": "subscription", "payment_status": "paid", "client_reference_id": fmt.Sprintf("%d", id), "customer": "cus_pf", "subscription": "sub_pf", "metadata": map[string]string{"pending_create": "1", "tier": "family", "band": "base"}, } } func TestCheckoutCreateMintsPendingSession(t *testing.T) { srv, s, form, _, _, _ := payFirstRig(t) tok := mintCaller(t, srv, "home") r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "family", "name": " Book Club ", "success_url": "https://home.example.com/?paidcreate={CHECKOUT_SESSION_ID}", "cancel_url": "https://home.example.com/", }) if r.StatusCode != http.StatusOK { t.Fatalf("checkout-create: %d, want 200", r.StatusCode) } rb := decodeBody(t, r) if u, _ := rb["url"].(string); !strings.Contains(u, "checkout.stripe.com") { t.Errorf("url = %q", u) } if sid, _ := rb["session_id"].(string); sid != "cs_pf_1" { t.Errorf("session_id = %q, want cs_pf_1 (the client's closed-tab marker needs it)", sid) } f := *form if f.Get("metadata[pending_create]") != "1" || f.Get("client_reference_id") != "1" || f.Get("line_items[0][price]") != "price_family_m" { t.Errorf("session form = %v", f) } // No name in Stripe's metadata — it stays in OUR ledger, on the pending row. for k, vs := range f { for _, v := range vs { if strings.Contains(v, "Book Club") { t.Errorf("space name leaked to Stripe via %s=%q", k, v) } } } row, ok, err := s.db.pendingSpaceByID(1) if err != nil || !ok { t.Fatalf("pending row: %v %v", ok, err) } if row.Entitlement != entPending || row.PendingName != "Book Club" || row.Host != "" { t.Errorf("row = %+v, want a pending, host-less allocation named Book Club", row) } // Validation: an oversized name and an unknown tier never reach Stripe. if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "family", "name": strings.Repeat("x", 61), "success_url": "https://h/", "cancel_url": "https://h/", }); r.StatusCode != http.StatusBadRequest { t.Errorf("61-char name: %d, want 400", r.StatusCode) } if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "platinum", "success_url": "https://h/", "cancel_url": "https://h/", }); r.StatusCode != http.StatusBadRequest { t.Errorf("unknown tier: %d, want 400", r.StatusCode) } } func TestClaimCreateProvisions(t *testing.T) { srv, s, _, sess, lastCreate, _ := payFirstRig(t) tok := mintCaller(t, srv, "home") if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "family", "name": "Book Club", "success_url": "https://h/?paidcreate={CHECKOUT_SESSION_ID}", "cancel_url": "https://h/", }); r.StatusCode != http.StatusOK { t.Fatalf("checkout-create: %d", r.StatusCode) } *sess = paidSessionFor(1) r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }) if r.StatusCode != http.StatusCreated { t.Fatalf("claim: %d, want 201", r.StatusCode) } body := decodeBody(t, r) host, _ := body["host"].(string) if host == "" || !strings.HasSuffix(host, ".example.com") { t.Fatalf("host = %q", host) } if link, _ := body["setup_link"].(string); !strings.HasPrefix(link, "https://"+host+"/join/setup#") { t.Errorf("setup_link = %q", link) } // The chosen name rode into the create; the row is a finalized paid space. if lastCreate.Name != "Book Club" || lastCreate.Entitlement != entPaid { t.Errorf("control saw %+v, want name Book Club, entitlement paid", *lastCreate) } row, _, _ := s.db.pendingSpaceByID(1) if row.Host != host || row.Entitlement != entPaid { t.Errorf("row after claim = %+v", row) } // Idempotent: a second claim answers 200 with the host and the parked link. r2 := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }) if r2.StatusCode != http.StatusOK { t.Fatalf("re-claim: %d, want 200", r2.StatusCode) } b2 := decodeBody(t, r2) if b2["host"] != host || b2["setup_link"] == nil { t.Errorf("re-claim body = %v, want host + parked setup_link", b2) } } func TestClaimCreateGates(t *testing.T) { srv, _, _, sess, _, _ := payFirstRig(t) tok := mintCaller(t, srv, "home") if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "family", "success_url": "https://h/", "cancel_url": "https://h/", }); r.StatusCode != http.StatusOK { t.Fatalf("checkout-create: %d", r.StatusCode) } // Unpaid session → 402, nothing provisioned. unpaid := paidSessionFor(1) unpaid["payment_status"] = "unpaid" *sess = unpaid if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }); r.StatusCode != http.StatusPaymentRequired { t.Errorf("unpaid claim: %d, want 402", r.StatusCode) } // Someone else's requester → 404 (ownership is the pending row's pair). *sess = paidSessionFor(1) if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "someone-else", "session_id": "cs_pf_1", }); r.StatusCode != http.StatusNotFound { t.Errorf("wrong requester: %d, want 404", r.StatusCode) } // A session without the pending_create marker (an upgrade checkout) can // never provision — even fully paid. upgrade := paidSessionFor(1) upgrade["metadata"] = map[string]string{"tier": "family", "band": "base"} *sess = upgrade if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }); r.StatusCode != http.StatusNotFound { t.Errorf("upgrade-session claim: %d, want 404", r.StatusCode) } } func TestClaimCreateRetriesAfterControlFailure(t *testing.T) { srv, s, _, sess, _, ctlFails := payFirstRig(t) tok := mintCaller(t, srv, "home") if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ "requester": "user-1", "tier": "family", "name": "Book Club", "success_url": "https://h/", "cancel_url": "https://h/", }); r.StatusCode != http.StatusOK { t.Fatalf("checkout-create: %d", r.StatusCode) } *sess = paidSessionFor(1) // First claim hits a control failure: 502, but the PAID row survives — // money is never rolled back. *ctlFails = 1 if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }); r.StatusCode != http.StatusBadGateway { t.Fatalf("claim with failing control: %d, want 502", r.StatusCode) } row, ok, _ := s.db.pendingSpaceByID(1) if !ok || row.Entitlement != entPaid || row.Host != "" { t.Fatalf("row after failed claim = %+v (ok=%v), want kept, paid, host-less", row, ok) } // The retry finishes the job. if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ "requester": "user-1", "session_id": "cs_pf_1", }); r.StatusCode != http.StatusCreated { t.Errorf("retry claim: %d, want 201", r.StatusCode) } } func TestPrunePendingCreates(t *testing.T) { _, s, _, _, _, _ := payFirstRig(t) old := time.Now().Add(-72 * time.Hour).Unix() stale, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Stale") if err != nil { t.Fatal(err) } paidStuck, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Paid but unclaimed") if err != nil { t.Fatal(err) } if err := s.db.markSpacePaid(paidStuck, "cus_1", "sub_1", "family", ""); err != nil { t.Fatal(err) } fresh, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Fresh") if err != nil { t.Fatal(err) } // Backdate the first two past the cutoff. for _, id := range []int64{stale, paidStuck} { if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = ? WHERE id = ?`, old, id); err != nil { t.Fatal(err) } } n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()) if err != nil { t.Fatal(err) } if n != 1 { t.Errorf("pruned %d, want 1 (only the stale pending row)", n) } if _, ok, _ := s.db.pendingSpaceByID(stale); ok { t.Error("stale pending row survived the prune") } // A paid-but-unclaimed row is money — never pruned; a fresh pending stays. if _, ok, _ := s.db.pendingSpaceByID(paidStuck); !ok { t.Error("paid row was pruned") } if _, ok, _ := s.db.pendingSpaceByID(fresh); !ok { t.Error("fresh pending row was pruned") } } // ---- destroy cancels the subscription ------------------------------------------ // destroyRig builds a cloud whose control answers create + delete and whose // Stripe stub records subscription cancels, answering each with the given // status/body. Returns the cancelled ids and a count of control-side deletes, // so tests can assert both "Stripe was told" and "nothing was deprovisioned". func destroyRig(t *testing.T, cancelStatus int, cancelBody map[string]any) (srv *httptest.Server, s *cloudServer, canceled *[]string, ctlDeletes *int) { t.Helper() canceled = &[]string{} ctlDeletes = new(int) stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions/") { *canceled = append(*canceled, strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/")) writeJSON(w, cancelStatus, cancelBody) return } w.WriteHeader(http.StatusNotFound) })) t.Cleanup(stripe.Close) ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == "DELETE" { *ctlDeletes++ writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } var req controlCreateReq json.NewDecoder(r.Body).Decode(&req) writeJSON(w, http.StatusCreated, map[string]any{"host": req.Host, "url": "https://" + req.Host}) }) srv, s = newTestCloud(t, ctl) s.billing = &stripeBilling{ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", tiers: []billingTier{{Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"}}, client: http.DefaultClient, } return srv, s, canceled, ctlDeletes } // deleteSpaceReq issues the owner-facing DELETE /api/instances/{host}. func deleteSpaceReq(t *testing.T, srv *httptest.Server, token, host, requester string) *http.Response { t.Helper() req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+host+"?requester="+requester, nil) req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // paidSpace creates a space as (tok, requester) and marks it paid with sub_x. func paidSpace(t *testing.T, srv *httptest.Server, s *cloudServer, tok string) (host string, id int64) { t.Helper() host, _ = decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) label, _, _ := strings.Cut(host, ".") id, _ = spaceID(label) if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { t.Fatal(err) } return host, id } func TestDestroyCancelsSubscription(t *testing.T) { srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"id": "sub_x", "status": "canceled"}) tok := mintCaller(t, srv, "home") host, _ := paidSpace(t, srv, s, tok) if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { t.Fatalf("destroy: %d, want 200", r.StatusCode) } if len(*canceled) != 1 || (*canceled)[0] != "sub_x" { t.Errorf("canceled = %v, want [sub_x]", *canceled) } if *ctlDeletes != 1 { t.Errorf("control deletes = %d, want 1", *ctlDeletes) } if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 0 { t.Errorf("spaces after destroy = %v (%v), want none", spaces, err) } } func TestDestroyBlockedWhenCancelFails(t *testing.T) { srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": "api_error", "message": "boom"}}) tok := mintCaller(t, srv, "home") host, _ := paidSpace(t, srv, s, tok) // The cancel failed → the whole destroy refuses: no deprovision, ledger row // kept, so a retry still knows the subscription. if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusBadGateway { t.Fatalf("destroy with failing cancel: %d, want 502", r.StatusCode) } if len(*canceled) != 1 { t.Errorf("cancel attempts = %d, want 1", len(*canceled)) } if *ctlDeletes != 0 { t.Errorf("control deletes = %d, want 0 (nothing deprovisioned)", *ctlDeletes) } if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 1 { t.Errorf("spaces after refused destroy = %v (%v), want the row kept", spaces, err) } } func TestDestroyToleratesGoneSubscription(t *testing.T) { // Stripe no longer knows the subscription (resource_missing) — the goal is // "not billing", so the destroy proceeds. srv, s, canceled, _ := destroyRig(t, http.StatusNotFound, map[string]any{"error": map[string]any{"code": "resource_missing", "message": "No such subscription"}}) tok := mintCaller(t, srv, "home") host, _ := paidSpace(t, srv, s, tok) if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { t.Fatalf("destroy: %d, want 200", r.StatusCode) } if len(*canceled) != 1 { t.Errorf("cancel attempts = %d, want 1", len(*canceled)) } } func TestDestroySkipsStripeWhenNotPaid(t *testing.T) { srv, s, canceled, _ := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) tok := mintCaller(t, srv, "home") // A free space: no subscription, no Stripe call. host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { t.Fatalf("destroy free: %d, want 200", r.StatusCode) } if len(*canceled) != 0 { t.Errorf("canceled = %v, want none for a free space", *canceled) } // A lapsed space keeps its old subscription id but is entitlement-free — // re-cancelling a canceled subscription is a Stripe error, so the gate is // the entitlement, not the id. host2, id2 := paidSpace(t, srv, s, tok) if err := s.db.setEntitlementTier(id2, entFree, "", "", 0, 0, false); err != nil { t.Fatal(err) } if r := deleteSpaceReq(t, srv, tok, host2, "user-1"); r.StatusCode != http.StatusOK { t.Fatalf("destroy lapsed: %d, want 200", r.StatusCode) } if len(*canceled) != 0 { t.Errorf("canceled = %v, want none for a lapsed space", *canceled) } } func TestDestroyPaidRefusesWithoutBilling(t *testing.T) { srv, s, _, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) tok := mintCaller(t, srv, "home") host, _ := paidSpace(t, srv, s, tok) // Billing unconfigured but the space has a live subscription: refusing is // the only move that doesn't leak a charge nobody can reach. s.billing = nil if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusConflict { t.Fatalf("destroy without billing: %d, want 409", r.StatusCode) } if *ctlDeletes != 0 { t.Errorf("control deletes = %d, want 0", *ctlDeletes) } }