| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/hmac" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "fmt" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | ) |
| 17 | |
| 18 | // fakeStripe stands in for api.stripe.com: it captures the checkout-session |
| 19 | // form and answers with a hosted-page URL, so the cloud server exercises its |
| 20 | // real HTTP path. |
| 21 | func fakeStripe(t *testing.T) (*httptest.Server, *url.Values) { |
| 22 | t.Helper() |
| 23 | captured := &url.Values{} |
| 24 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 25 | if r.URL.Path != "/v1/checkout/sessions" { |
| 26 | w.WriteHeader(http.StatusNotFound) |
| 27 | return |
| 28 | } |
| 29 | if err := r.ParseForm(); err != nil { |
| 30 | w.WriteHeader(http.StatusBadRequest) |
| 31 | return |
| 32 | } |
| 33 | *captured = r.PostForm |
| 34 | writeJSON(w, http.StatusOK, map[string]any{ |
| 35 | "id": "cs_test_123", "url": "https://checkout.stripe.com/c/pay/cs_test_123", |
| 36 | }) |
| 37 | })) |
| 38 | t.Cleanup(srv.Close) |
| 39 | return srv, captured |
| 40 | } |
| 41 | |
| 42 | // billingTestCloud is newTestCloud plus a configured stripeBilling pointed at |
| 43 | // the fake. |
| 44 | func billingTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values) { |
| 45 | t.Helper() |
| 46 | stripe, captured := fakeStripe(t) |
| 47 | srv, s := newTestCloud(t, okControl(t)) |
| 48 | s.billing = &stripeBilling{ |
| 49 | apiBase: stripe.URL, |
| 50 | key: "sk_test_secret", |
| 51 | webhookSecret: "whsec_test_secret", |
| 52 | tiers: []billingTier{ |
| 53 | {Key: "family", Name: "Family", Label: "$5/year", Price: "price_family_y"}, |
| 54 | {Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"}, |
| 55 | {Key: "business", Name: "Business", Label: "$99/month", Price: "price_business_m"}, |
| 56 | }, |
| 57 | client: http.DefaultClient, |
| 58 | } |
| 59 | return srv, s, captured |
| 60 | } |
| 61 | |
| 62 | // postCheckout starts a checkout for host as (token, requester), buying tier. |
| 63 | func postCheckout(t *testing.T, srv *httptest.Server, token, host, requester, tier string) *http.Response { |
| 64 | t.Helper() |
| 65 | body, _ := json.Marshal(map[string]string{ |
| 66 | "requester": requester, |
| 67 | "tier": tier, |
| 68 | "success_url": "https://home.example.com/?upgraded=" + host, |
| 69 | "cancel_url": "https://home.example.com/", |
| 70 | }) |
| 71 | req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", bytes.NewReader(body)) |
| 72 | if token != "" { |
| 73 | req.Header.Set("Authorization", "Bearer "+token) |
| 74 | } |
| 75 | resp, err := http.DefaultClient.Do(req) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | t.Cleanup(func() { resp.Body.Close() }) |
| 80 | return resp |
| 81 | } |
| 82 | |
| 83 | // stripeSign builds a valid Stripe-Signature header for payload at time now. |
| 84 | func stripeSign(payload []byte, secret string, now time.Time) string { |
| 85 | ts := fmt.Sprintf("%d", now.Unix()) |
| 86 | mac := hmac.New(sha256.New, []byte(secret)) |
| 87 | mac.Write([]byte(ts + ".")) |
| 88 | mac.Write(payload) |
| 89 | return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil)) |
| 90 | } |
| 91 | |
| 92 | // postWebhook delivers an event payload with the given signature header. |
| 93 | func postWebhook(t *testing.T, srv *httptest.Server, payload []byte, sig string) *http.Response { |
| 94 | t.Helper() |
| 95 | req, _ := http.NewRequest("POST", srv.URL+"/api/stripe/webhook", bytes.NewReader(payload)) |
| 96 | req.Header.Set("Stripe-Signature", sig) |
| 97 | resp, err := http.DefaultClient.Do(req) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | t.Cleanup(func() { resp.Body.Close() }) |
| 102 | return resp |
| 103 | } |
| 104 | |
| 105 | func TestCheckoutMintsSession(t *testing.T) { |
| 106 | srv, _, captured := billingTestCloud(t) |
| 107 | tok := mintCaller(t, srv, "home") |
| 108 | host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 109 | |
| 110 | resp := postCheckout(t, srv, tok, host, "user-1", "friends") |
| 111 | if resp.StatusCode != http.StatusOK { |
| 112 | t.Fatalf("status = %d, want 200", resp.StatusCode) |
| 113 | } |
| 114 | if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "checkout.stripe.com") { |
| 115 | t.Errorf("url = %q, want the hosted checkout page", u) |
| 116 | } |
| 117 | |
| 118 | // The session carried the space's ledger id (id 1 — fresh DB) in both |
| 119 | // routing slots, the chosen tier's price + key, and NO customer details. |
| 120 | f := *captured |
| 121 | if f.Get("mode") != "subscription" || f.Get("line_items[0][price]") != "price_friends_m" { |
| 122 | t.Errorf("form = %v", f) |
| 123 | } |
| 124 | if f.Get("client_reference_id") != "1" || f.Get("subscription_data[metadata][space_id]") != "1" { |
| 125 | t.Errorf("space id routing: client_reference_id=%q metadata=%q, want 1/1", |
| 126 | f.Get("client_reference_id"), f.Get("subscription_data[metadata][space_id]")) |
| 127 | } |
| 128 | if f.Get("metadata[tier]") != "friends" || f.Get("subscription_data[metadata][tier]") != "friends" { |
| 129 | t.Errorf("tier routing: session=%q subscription=%q, want friends/friends", |
| 130 | f.Get("metadata[tier]"), f.Get("subscription_data[metadata][tier]")) |
| 131 | } |
| 132 | if !strings.Contains(f.Get("success_url"), "upgraded="+host) { |
| 133 | t.Errorf("success_url = %q", f.Get("success_url")) |
| 134 | } |
| 135 | if f.Get("allow_promotion_codes") != "true" { |
| 136 | t.Errorf("allow_promotion_codes = %q, want true (dashboard promo codes must be redeemable)", f.Get("allow_promotion_codes")) |
| 137 | } |
| 138 | for k := range f { |
| 139 | if strings.Contains(k, "customer") || strings.Contains(k, "email") { |
| 140 | t.Errorf("checkout form leaks a customer field: %s", k) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestCheckoutGates(t *testing.T) { |
| 146 | srv, s, _ := billingTestCloud(t) |
| 147 | tok := mintCaller(t, srv, "home") |
| 148 | host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 149 | |
| 150 | // No caller token → 401. |
| 151 | if r := postCheckout(t, srv, "", host, "user-1", "friends"); r.StatusCode != http.StatusUnauthorized { |
| 152 | t.Errorf("no token: %d, want 401", r.StatusCode) |
| 153 | } |
| 154 | // Ownership is the gate: someone else's requester id → 404. |
| 155 | if r := postCheckout(t, srv, tok, host, "someone-else", "friends"); r.StatusCode != http.StatusNotFound { |
| 156 | t.Errorf("wrong requester: %d, want 404", r.StatusCode) |
| 157 | } |
| 158 | // Unknown tier → 400. |
| 159 | if r := postCheckout(t, srv, tok, host, "user-1", "platinum"); r.StatusCode != http.StatusBadRequest { |
| 160 | t.Errorf("unknown tier: %d, want 400", r.StatusCode) |
| 161 | } |
| 162 | // Missing redirect URLs → 400. |
| 163 | req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout", |
| 164 | strings.NewReader(`{"requester":"user-1","tier":"friends"}`)) |
| 165 | req.Header.Set("Authorization", "Bearer "+tok) |
| 166 | resp, err := http.DefaultClient.Do(req) |
| 167 | if err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | resp.Body.Close() |
| 171 | if resp.StatusCode != http.StatusBadRequest { |
| 172 | t.Errorf("no urls: %d, want 400", resp.StatusCode) |
| 173 | } |
| 174 | // Already upgraded → 409. |
| 175 | label, _, _ := strings.Cut(host, ".") |
| 176 | id, _ := spaceID(label) |
| 177 | if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusConflict { |
| 181 | t.Errorf("already paid: %d, want 409", r.StatusCode) |
| 182 | } |
| 183 | // Billing off → 501 (and the webhook too). |
| 184 | s.billing = nil |
| 185 | if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusNotImplemented { |
| 186 | t.Errorf("billing off: %d, want 501", r.StatusCode) |
| 187 | } |
| 188 | if r := postWebhook(t, srv, []byte("{}"), "t=1,v1=00"); r.StatusCode != http.StatusNotImplemented { |
| 189 | t.Errorf("webhook, billing off: %d, want 501", r.StatusCode) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestWebhookRejectsBadSignatures(t *testing.T) { |
| 194 | srv, _, _ := billingTestCloud(t) |
| 195 | payload := []byte(`{"type":"checkout.session.completed","data":{"object":{}}}`) |
| 196 | |
| 197 | // No header / garbage header / wrong secret / stale timestamp → all 400. |
| 198 | if r := postWebhook(t, srv, payload, ""); r.StatusCode != http.StatusBadRequest { |
| 199 | t.Errorf("no header: %d, want 400", r.StatusCode) |
| 200 | } |
| 201 | if r := postWebhook(t, srv, payload, "not-a-header"); r.StatusCode != http.StatusBadRequest { |
| 202 | t.Errorf("garbage header: %d, want 400", r.StatusCode) |
| 203 | } |
| 204 | if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_WRONG", time.Now())); r.StatusCode != http.StatusBadRequest { |
| 205 | t.Errorf("wrong secret: %d, want 400", r.StatusCode) |
| 206 | } |
| 207 | stale := stripeSign(payload, "whsec_test_secret", time.Now().Add(-time.Hour)) |
| 208 | if r := postWebhook(t, srv, payload, stale); r.StatusCode != http.StatusBadRequest { |
| 209 | t.Errorf("stale timestamp: %d, want 400 (replay window)", r.StatusCode) |
| 210 | } |
| 211 | // And the real thing → 200. |
| 212 | if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { |
| 213 | t.Errorf("valid signature: %d, want 200", r.StatusCode) |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestWebhookEntitlementLifecycle(t *testing.T) { |
| 218 | srv, s, _ := billingTestCloud(t) |
| 219 | tok := mintCaller(t, srv, "home") |
| 220 | host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 221 | |
| 222 | deliver := func(event string) { |
| 223 | t.Helper() |
| 224 | payload := []byte(event) |
| 225 | if r := postWebhook(t, srv, payload, stripeSign(payload, "whsec_test_secret", time.Now())); r.StatusCode != http.StatusOK { |
| 226 | t.Fatalf("webhook: %d, want 200", r.StatusCode) |
| 227 | } |
| 228 | } |
| 229 | entitlement := func() Space { |
| 230 | t.Helper() |
| 231 | spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1") |
| 232 | if err != nil || len(spaces) != 1 { |
| 233 | t.Fatalf("listSpaces: %v (%d rows)", err, len(spaces)) |
| 234 | } |
| 235 | return spaces[0] |
| 236 | } |
| 237 | |
| 238 | if got := entitlement(); got.Entitlement != entFree { |
| 239 | t.Fatalf("fresh space entitlement = %q, want free", got.Entitlement) |
| 240 | } |
| 241 | |
| 242 | // Checkout completes → paid, ids + tier recorded. |
| 243 | deliver(`{"type":"checkout.session.completed","data":{"object":{ |
| 244 | "mode":"subscription","client_reference_id":"1","customer":"cus_9","subscription":"sub_9", |
| 245 | "metadata":{"tier":"family"}}}}`) |
| 246 | if got := entitlement(); got.Entitlement != entPaid || got.Tier != "family" { |
| 247 | t.Fatalf("after checkout: %+v, want paid/family", got) |
| 248 | } |
| 249 | |
| 250 | // A subscription update (routed by metadata) refreshes the period end — |
| 251 | // item-level current_period_end (2025+ API shape). |
| 252 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 253 | "id":"sub_9","status":"active","metadata":{"space_id":"1"}, |
| 254 | "items":{"data":[{"current_period_end":1900000000}]}}}}`) |
| 255 | if got := entitlement(); got.Entitlement != entPaid || got.PaidUntil != 1900000000 { |
| 256 | t.Fatalf("after update: %+v, want paid until 1900000000", got) |
| 257 | } |
| 258 | |
| 259 | // An update WITHOUT metadata still routes — by the stored subscription id — |
| 260 | // and a top-level current_period_end (pre-2025 shape) works too. |
| 261 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 262 | "id":"sub_9","status":"active","current_period_end":1900000099}}}`) |
| 263 | if got := entitlement(); got.PaidUntil != 1900000099 { |
| 264 | t.Fatalf("fallback routing: paid_until = %d, want 1900000099", got.PaidUntil) |
| 265 | } |
| 266 | |
| 267 | // A Billing Portal plan switch arrives as an update with a NEW price and |
| 268 | // the ORIGINAL metadata — the price wins: tier follows it. |
| 269 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 270 | "id":"sub_9","status":"active","metadata":{"space_id":"1","tier":"family"}, |
| 271 | "items":{"data":[{"current_period_end":1900000200,"price":{"id":"price_business_m"}}]}}}}`) |
| 272 | if got := entitlement(); got.Tier != "business" || got.PaidUntil != 1900000200 { |
| 273 | t.Fatalf("after plan switch: %+v, want tier business", got) |
| 274 | } |
| 275 | |
| 276 | // The payer cancels (portal, at period end): still paid, but the listing |
| 277 | // says the subscription runs out — and resuming clears it. |
| 278 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 279 | "id":"sub_9","status":"active","cancel_at_period_end":true,"metadata":{"space_id":"1"}, |
| 280 | "current_period_end":1900000300}}}`) |
| 281 | if got := entitlement(); got.Entitlement != entPaid || !got.CancelAtPeriodEnd || got.PaidUntil != 1900000300 { |
| 282 | t.Fatalf("after cancel: %+v, want paid + cancelling at 1900000300", got) |
| 283 | } |
| 284 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 285 | "id":"sub_9","status":"active","cancel_at_period_end":false,"metadata":{"space_id":"1"}, |
| 286 | "current_period_end":1900000300}}}`) |
| 287 | if got := entitlement(); got.CancelAtPeriodEnd { |
| 288 | t.Fatalf("after resume: %+v, want cancelling cleared", got) |
| 289 | } |
| 290 | |
| 291 | // Dunning keeps the space paid; a dead subscription drops it to free. |
| 292 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 293 | "id":"sub_9","status":"past_due","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) |
| 294 | if got := entitlement(); got.Entitlement != entPaid { |
| 295 | t.Fatalf("past_due should keep access, got %q", got.Entitlement) |
| 296 | } |
| 297 | deliver(`{"type":"customer.subscription.deleted","data":{"object":{ |
| 298 | "id":"sub_9","status":"canceled","metadata":{"space_id":"1"},"current_period_end":1900000099}}}`) |
| 299 | if got := entitlement(); got.Entitlement != entFree { |
| 300 | t.Fatalf("after deletion: entitlement = %q, want free", got.Entitlement) |
| 301 | } |
| 302 | |
| 303 | // Events for spaces that don't exist are acknowledged, never retried. |
| 304 | deliver(`{"type":"checkout.session.completed","data":{"object":{ |
| 305 | "mode":"subscription","client_reference_id":"999","customer":"c","subscription":"s"}}}`) |
| 306 | deliver(`{"type":"customer.subscription.updated","data":{"object":{ |
| 307 | "id":"sub_unknown","status":"active"}}}`) |
| 308 | // Unknown event types too. |
| 309 | deliver(`{"type":"invoice.paid","data":{"object":{}}}`) |
| 310 | |
| 311 | _ = host // exercised above via createSpace |
| 312 | } |
| 313 | |
| 314 | func TestVerifyStripeSignature(t *testing.T) { |
| 315 | payload := []byte(`{"hello":"world"}`) |
| 316 | secret := "whsec_abc" |
| 317 | now := time.Unix(1_900_000_000, 0) |
| 318 | |
| 319 | good := stripeSign(payload, secret, now) |
| 320 | if !verifyStripeSignature(payload, good, secret, now) { |
| 321 | t.Error("valid signature rejected") |
| 322 | } |
| 323 | // Tolerance: inside ±5m passes, outside fails (either direction). |
| 324 | if !verifyStripeSignature(payload, good, secret, now.Add(4*time.Minute)) { |
| 325 | t.Error("signature inside the window rejected") |
| 326 | } |
| 327 | if verifyStripeSignature(payload, good, secret, now.Add(6*time.Minute)) { |
| 328 | t.Error("stale signature accepted (replay window)") |
| 329 | } |
| 330 | if verifyStripeSignature(payload, good, secret, now.Add(-6*time.Minute)) { |
| 331 | t.Error("future-dated signature accepted") |
| 332 | } |
| 333 | // Tampered payload / wrong secret / v0-only fail. |
| 334 | if verifyStripeSignature([]byte(`{"hello":"tampered"}`), good, secret, now) { |
| 335 | t.Error("tampered payload accepted") |
| 336 | } |
| 337 | if verifyStripeSignature(payload, good, "whsec_other", now) { |
| 338 | t.Error("wrong secret accepted") |
| 339 | } |
| 340 | v0 := strings.Replace(good, "v1=", "v0=", 1) |
| 341 | if verifyStripeSignature(payload, v0, secret, now) { |
| 342 | t.Error("v0-only header accepted") |
| 343 | } |
| 344 | // An extra bogus v1 alongside the real one still passes (Stripe sends |
| 345 | // multiples during secret rolls). |
| 346 | multi := good + ",v1=" + strings.Repeat("00", 32) |
| 347 | if !verifyStripeSignature(payload, multi, secret, now) { |
| 348 | t.Error("multiple v1 signatures: the valid one should win") |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | // TestReaperNeverSeesPaidSpaces pins the strongest billing invariant: a paid |
| 353 | // space is not even a reap CANDIDATE, no matter how old or empty. |
| 354 | func TestReaperNeverSeesPaidSpaces(t *testing.T) { |
| 355 | db := openTestCloudDB(t) |
| 356 | now := time.Unix(2_000_000_000, 0) |
| 357 | old := now.Add(-20 * 24 * time.Hour).Unix() |
| 358 | paidID := insertSpaceRow(t, db, "paid.11ch.at", old) |
| 359 | freeID := insertSpaceRow(t, db, "free.11ch.at", old) |
| 360 | if err := db.markSpacePaid(paidID, "cus_1", "sub_1", "friends", ""); err != nil { |
| 361 | t.Fatal(err) |
| 362 | } |
| 363 | |
| 364 | cands, err := db.spacesOlderThan(now.Unix()) |
| 365 | if err != nil { |
| 366 | t.Fatal(err) |
| 367 | } |
| 368 | if len(cands) != 1 || cands[0].ID != freeID { |
| 369 | t.Fatalf("candidates = %+v, want only the free space (id %d)", cands, freeID) |
| 370 | } |
| 371 | |
| 372 | // And end-to-end: a sweep deletes the free one, never the paid one — even |
| 373 | // though both are old, never set up, single-member, contentless. |
| 374 | deleted := map[string]bool{} |
| 375 | sock := reapControl(t, |
| 376 | map[string]int{"paid.11ch.at": 1, "free.11ch.at": 1}, |
| 377 | nil, nil, deleted, nil) |
| 378 | s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t", reapAfter: 14 * 24 * time.Hour} |
| 379 | if reaped, _ := s.reapOnce(now); reaped != 1 { |
| 380 | t.Fatalf("reaped = %d, want 1", reaped) |
| 381 | } |
| 382 | if deleted["paid.11ch.at"] { |
| 383 | t.Error("a paid space must NEVER be reaped") |
| 384 | } |
| 385 | if !deleted["free.11ch.at"] { |
| 386 | t.Error("the free never-claimed space should have been reaped") |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | // TestBillingMigrationOverOldDB proves the billing columns land additively on a |
| 391 | // pre-billing spaces table (the deployed cloud.db shape). |
| 392 | func TestBillingMigrationOverOldDB(t *testing.T) { |
| 393 | db := openTestCloudDB(t) // migrated schema |
| 394 | // Simulate re-open over existing data (migrations must be no-ops then). |
| 395 | id := insertSpaceRow(t, db, "existing.11ch.at", 100) |
| 396 | if err := db.migrate(); err != nil { |
| 397 | t.Fatalf("re-migrate: %v", err) |
| 398 | } |
| 399 | ent, err := db.spaceEntitlement(id) |
| 400 | if err != nil || ent != entFree { |
| 401 | t.Fatalf("existing row entitlement = %q (%v), want free", ent, err) |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | func TestNewStripeBillingConfig(t *testing.T) { |
| 406 | prices := `[{"key":"friends","name":"Friends","label":"$4/month","price":"price_x"}]` |
| 407 | if b, err := newStripeBilling("", "", "", "https://api.stripe.com"); b != nil || err != nil { |
| 408 | t.Errorf("all-empty: (%v, %v), want (nil, nil) — billing off", b, err) |
| 409 | } |
| 410 | if _, err := newStripeBilling("sk_x", "", "", "https://api.stripe.com"); err == nil { |
| 411 | t.Error("partial config must error, not half-work") |
| 412 | } |
| 413 | 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 { |
| 414 | t.Errorf("full config: (%+v, %v)", b, err) |
| 415 | } |
| 416 | // Malformed price books refuse to start: bad JSON, empty, missing fields, dup keys. |
| 417 | for _, bad := range []string{ |
| 418 | "not-json", |
| 419 | "[]", |
| 420 | `[{"key":"a","name":"A","label":"$1"}]`, |
| 421 | `[{"key":"a","name":"A","label":"$1","price":"p1"},{"key":"a","name":"B","label":"$2","price":"p2"}]`, |
| 422 | } { |
| 423 | if _, err := newStripeBilling("sk_x", "whsec_x", bad, "https://api.stripe.com"); err == nil { |
| 424 | t.Errorf("prices %q accepted, want error", bad) |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | // TestQuotaExemptsPaidSpaces pins the point of the tier system: paid spaces |
| 430 | // don't count toward the creation quota, so upgrading one frees a slot. |
| 431 | func TestQuotaExemptsPaidSpaces(t *testing.T) { |
| 432 | srv, s, _ := billingTestCloud(t) |
| 433 | tok := mintCaller(t, srv, "home") |
| 434 | |
| 435 | // Fill the quota with free spaces. |
| 436 | var firstHost string |
| 437 | for i := 0; i < s.quota; i++ { |
| 438 | resp := createSpace(t, srv, tok, "user-1") |
| 439 | if resp.StatusCode != http.StatusCreated { |
| 440 | t.Fatalf("create %d: status %d", i, resp.StatusCode) |
| 441 | } |
| 442 | if i == 0 { |
| 443 | firstHost, _ = decodeBody(t, resp)["host"].(string) |
| 444 | } |
| 445 | } |
| 446 | if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict { |
| 447 | t.Fatalf("over quota: status = %d, want 409", resp.StatusCode) |
| 448 | } |
| 449 | |
| 450 | // Upgrade the first space → its slot frees → creation works again. |
| 451 | label, _, _ := strings.Cut(firstHost, ".") |
| 452 | id, _ := spaceID(label) |
| 453 | if err := s.db.markSpacePaid(id, "cus_1", "sub_1", "friends", ""); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated { |
| 457 | t.Fatalf("after upgrade: status = %d, want 201 (paid space freed a slot)", resp.StatusCode) |
| 458 | } |
| 459 | // And the limit still holds for the free ones. |
| 460 | if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict { |
| 461 | t.Fatalf("refilled quota: status = %d, want 409", resp.StatusCode) |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | // TestCreateBudgetCircuitBreaker pins the fleet-wide throttle: past the |
| 466 | // trailing-24h budget, creation answers the capacity message — and deleting |
| 467 | // spaces doesn't reset it (the log is append-only). |
| 468 | func TestCreateBudgetCircuitBreaker(t *testing.T) { |
| 469 | srv, s, _ := billingTestCloud(t) |
| 470 | s.createBudget = 2 |
| 471 | tok := mintCaller(t, srv, "home") |
| 472 | |
| 473 | var firstHost string |
| 474 | for i := 0; i < 2; i++ { |
| 475 | resp := createSpace(t, srv, tok, "user-1") |
| 476 | if resp.StatusCode != http.StatusCreated { |
| 477 | t.Fatalf("create %d: %d", i, resp.StatusCode) |
| 478 | } |
| 479 | if i == 0 { |
| 480 | firstHost, _ = decodeBody(t, resp)["host"].(string) |
| 481 | } |
| 482 | } |
| 483 | resp := createSpace(t, srv, tok, "user-2") // a DIFFERENT account — budget is global |
| 484 | if resp.StatusCode != http.StatusServiceUnavailable { |
| 485 | t.Fatalf("over budget: %d, want 503", resp.StatusCode) |
| 486 | } |
| 487 | if m := decodeBody(t, resp); m["error"] != "at_capacity" || m["message"] != spaceUnavailableMsg { |
| 488 | t.Errorf("over budget body = %v", m) |
| 489 | } |
| 490 | |
| 491 | // Deleting a space frees its quota slot but NOT the creation budget: |
| 492 | // the log is append-only, so create-and-delete can't reset the throttle. |
| 493 | label, _, _ := strings.Cut(firstHost, ".") |
| 494 | id, _ := spaceID(label) |
| 495 | if err := s.db.deleteSpaceByID(id); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | if r := createSpace(t, srv, tok, "user-1"); r.StatusCode != http.StatusServiceUnavailable { |
| 499 | t.Errorf("post-delete create: %d, want 503 (budget must not reset)", r.StatusCode) |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | // TestPortalSession pins the manage path: only an upgraded space's owner gets |
| 504 | // a portal link, minted for that space's own Stripe customer. |
| 505 | func TestPortalSession(t *testing.T) { |
| 506 | stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 507 | if r.URL.Path != "/v1/billing_portal/sessions" { |
| 508 | w.WriteHeader(http.StatusNotFound) |
| 509 | return |
| 510 | } |
| 511 | r.ParseForm() |
| 512 | if r.PostForm.Get("customer") != "cus_9" || r.PostForm.Get("return_url") == "" { |
| 513 | t.Errorf("portal form = %v", r.PostForm) |
| 514 | } |
| 515 | writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"}) |
| 516 | })) |
| 517 | t.Cleanup(stripe.Close) |
| 518 | |
| 519 | srv, s, _ := billingTestCloud(t) |
| 520 | s.billing.apiBase = stripe.URL |
| 521 | tok := mintCaller(t, srv, "home") |
| 522 | host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 523 | |
| 524 | post := func(requester string) *http.Response { |
| 525 | body, _ := json.Marshal(map[string]string{"requester": requester, "return_url": "https://home.example.com/"}) |
| 526 | req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body)) |
| 527 | req.Header.Set("Authorization", "Bearer "+tok) |
| 528 | resp, err := http.DefaultClient.Do(req) |
| 529 | if err != nil { |
| 530 | t.Fatal(err) |
| 531 | } |
| 532 | t.Cleanup(func() { resp.Body.Close() }) |
| 533 | return resp |
| 534 | } |
| 535 | |
| 536 | // Not upgraded yet → 409 not_subscribed; wrong owner → 404. |
| 537 | if r := post("user-1"); r.StatusCode != http.StatusConflict { |
| 538 | t.Fatalf("unsubscribed portal: %d, want 409", r.StatusCode) |
| 539 | } |
| 540 | if r := post("someone-else"); r.StatusCode != http.StatusNotFound { |
| 541 | t.Fatalf("wrong owner: %d, want 404", r.StatusCode) |
| 542 | } |
| 543 | |
| 544 | // Upgrade it, then the portal link mints against its customer. |
| 545 | label, _, _ := strings.Cut(host, ".") |
| 546 | id, _ := spaceID(label) |
| 547 | if err := s.db.markSpacePaid(id, "cus_9", "sub_9", "family", ""); err != nil { |
| 548 | t.Fatal(err) |
| 549 | } |
| 550 | resp := post("user-1") |
| 551 | if resp.StatusCode != http.StatusOK { |
| 552 | t.Fatalf("portal: %d, want 200", resp.StatusCode) |
| 553 | } |
| 554 | if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "billing.stripe.com") { |
| 555 | t.Errorf("url = %q", u) |
| 556 | } |
| 557 | } |
| 558 | |
| 559 | // ---- pay-before-create ----------------------------------------------------------- |
| 560 | |
| 561 | // payFirstRig: a cloud with billing pointed at a Stripe stub that mints |
| 562 | // checkout sessions (capturing the form) and serves one retrievable session |
| 563 | // (mutable — tests shape it per step), plus a control that records the last |
| 564 | // create and can be told to fail n times. |
| 565 | func payFirstRig(t *testing.T) (srv *httptest.Server, s *cloudServer, form *url.Values, sess *map[string]any, lastCreate *controlCreateReq, ctlFails *int) { |
| 566 | t.Helper() |
| 567 | form = &url.Values{} |
| 568 | sess = &map[string]any{} |
| 569 | stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 570 | switch { |
| 571 | case r.Method == "POST" && r.URL.Path == "/v1/checkout/sessions": |
| 572 | r.ParseForm() |
| 573 | *form = r.PostForm |
| 574 | writeJSON(w, http.StatusOK, map[string]any{ |
| 575 | "id": "cs_pf_1", "url": "https://checkout.stripe.com/c/pay/cs_pf_1", |
| 576 | }) |
| 577 | case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/"): |
| 578 | writeJSON(w, http.StatusOK, *sess) |
| 579 | default: |
| 580 | w.WriteHeader(http.StatusNotFound) |
| 581 | } |
| 582 | })) |
| 583 | t.Cleanup(stripe.Close) |
| 584 | lastCreate = &controlCreateReq{} |
| 585 | ctlFails = new(int) |
| 586 | ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| 587 | var req controlCreateReq |
| 588 | json.NewDecoder(r.Body).Decode(&req) |
| 589 | *lastCreate = req |
| 590 | if *ctlFails > 0 { |
| 591 | *ctlFails-- |
| 592 | writeControlErr(w, http.StatusInternalServerError, "create_failed", "boom") |
| 593 | return |
| 594 | } |
| 595 | writeJSON(w, http.StatusCreated, map[string]any{ |
| 596 | "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#PF", |
| 597 | }) |
| 598 | }) |
| 599 | srv, s = newTestCloud(t, ctl) |
| 600 | s.billing = &stripeBilling{ |
| 601 | apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", |
| 602 | tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50/month", Price: "price_family_m"}}, |
| 603 | client: http.DefaultClient, |
| 604 | } |
| 605 | return |
| 606 | } |
| 607 | |
| 608 | // postCloud posts a JSON body to a caller-token-gated cloud endpoint. |
| 609 | func postCloud(t *testing.T, srv *httptest.Server, tok, path string, body map[string]string) *http.Response { |
| 610 | t.Helper() |
| 611 | payload, _ := json.Marshal(body) |
| 612 | req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(payload)) |
| 613 | req.Header.Set("Authorization", "Bearer "+tok) |
| 614 | resp, err := http.DefaultClient.Do(req) |
| 615 | if err != nil { |
| 616 | t.Fatal(err) |
| 617 | } |
| 618 | t.Cleanup(func() { resp.Body.Close() }) |
| 619 | return resp |
| 620 | } |
| 621 | |
| 622 | // paidSessionFor shapes the stub's retrievable session as a completed pay-first |
| 623 | // checkout for ledger row id. |
| 624 | func paidSessionFor(id int64) map[string]any { |
| 625 | return map[string]any{ |
| 626 | "id": "cs_pf_1", "mode": "subscription", "payment_status": "paid", |
| 627 | "client_reference_id": fmt.Sprintf("%d", id), |
| 628 | "customer": "cus_pf", "subscription": "sub_pf", |
| 629 | "metadata": map[string]string{"pending_create": "1", "tier": "family", "band": "base"}, |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | func TestCheckoutCreateMintsPendingSession(t *testing.T) { |
| 634 | srv, s, form, _, _, _ := payFirstRig(t) |
| 635 | tok := mintCaller(t, srv, "home") |
| 636 | |
| 637 | r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 638 | "requester": "user-1", "tier": "family", "name": " Book Club ", |
| 639 | "success_url": "https://home.example.com/?paidcreate={CHECKOUT_SESSION_ID}", |
| 640 | "cancel_url": "https://home.example.com/", |
| 641 | }) |
| 642 | if r.StatusCode != http.StatusOK { |
| 643 | t.Fatalf("checkout-create: %d, want 200", r.StatusCode) |
| 644 | } |
| 645 | rb := decodeBody(t, r) |
| 646 | if u, _ := rb["url"].(string); !strings.Contains(u, "checkout.stripe.com") { |
| 647 | t.Errorf("url = %q", u) |
| 648 | } |
| 649 | if sid, _ := rb["session_id"].(string); sid != "cs_pf_1" { |
| 650 | t.Errorf("session_id = %q, want cs_pf_1 (the client's closed-tab marker needs it)", sid) |
| 651 | } |
| 652 | f := *form |
| 653 | if f.Get("metadata[pending_create]") != "1" || f.Get("client_reference_id") != "1" || f.Get("line_items[0][price]") != "price_family_m" { |
| 654 | t.Errorf("session form = %v", f) |
| 655 | } |
| 656 | // No name in Stripe's metadata — it stays in OUR ledger, on the pending row. |
| 657 | for k, vs := range f { |
| 658 | for _, v := range vs { |
| 659 | if strings.Contains(v, "Book Club") { |
| 660 | t.Errorf("space name leaked to Stripe via %s=%q", k, v) |
| 661 | } |
| 662 | } |
| 663 | } |
| 664 | row, ok, err := s.db.pendingSpaceByID(1) |
| 665 | if err != nil || !ok { |
| 666 | t.Fatalf("pending row: %v %v", ok, err) |
| 667 | } |
| 668 | if row.Entitlement != entPending || row.PendingName != "Book Club" || row.Host != "" { |
| 669 | t.Errorf("row = %+v, want a pending, host-less allocation named Book Club", row) |
| 670 | } |
| 671 | |
| 672 | // Validation: an oversized name and an unknown tier never reach Stripe. |
| 673 | if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 674 | "requester": "user-1", "tier": "family", "name": strings.Repeat("x", 61), |
| 675 | "success_url": "https://h/", "cancel_url": "https://h/", |
| 676 | }); r.StatusCode != http.StatusBadRequest { |
| 677 | t.Errorf("61-char name: %d, want 400", r.StatusCode) |
| 678 | } |
| 679 | if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 680 | "requester": "user-1", "tier": "platinum", |
| 681 | "success_url": "https://h/", "cancel_url": "https://h/", |
| 682 | }); r.StatusCode != http.StatusBadRequest { |
| 683 | t.Errorf("unknown tier: %d, want 400", r.StatusCode) |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | func TestClaimCreateProvisions(t *testing.T) { |
| 688 | srv, s, _, sess, lastCreate, _ := payFirstRig(t) |
| 689 | tok := mintCaller(t, srv, "home") |
| 690 | if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 691 | "requester": "user-1", "tier": "family", "name": "Book Club", |
| 692 | "success_url": "https://h/?paidcreate={CHECKOUT_SESSION_ID}", "cancel_url": "https://h/", |
| 693 | }); r.StatusCode != http.StatusOK { |
| 694 | t.Fatalf("checkout-create: %d", r.StatusCode) |
| 695 | } |
| 696 | *sess = paidSessionFor(1) |
| 697 | |
| 698 | r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 699 | "requester": "user-1", "session_id": "cs_pf_1", |
| 700 | }) |
| 701 | if r.StatusCode != http.StatusCreated { |
| 702 | t.Fatalf("claim: %d, want 201", r.StatusCode) |
| 703 | } |
| 704 | body := decodeBody(t, r) |
| 705 | host, _ := body["host"].(string) |
| 706 | if host == "" || !strings.HasSuffix(host, ".example.com") { |
| 707 | t.Fatalf("host = %q", host) |
| 708 | } |
| 709 | if link, _ := body["setup_link"].(string); !strings.HasPrefix(link, "https://"+host+"/join/setup#") { |
| 710 | t.Errorf("setup_link = %q", link) |
| 711 | } |
| 712 | // The chosen name rode into the create; the row is a finalized paid space. |
| 713 | if lastCreate.Name != "Book Club" || lastCreate.Entitlement != entPaid { |
| 714 | t.Errorf("control saw %+v, want name Book Club, entitlement paid", *lastCreate) |
| 715 | } |
| 716 | row, _, _ := s.db.pendingSpaceByID(1) |
| 717 | if row.Host != host || row.Entitlement != entPaid { |
| 718 | t.Errorf("row after claim = %+v", row) |
| 719 | } |
| 720 | |
| 721 | // Idempotent: a second claim answers 200 with the host and the parked link. |
| 722 | r2 := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 723 | "requester": "user-1", "session_id": "cs_pf_1", |
| 724 | }) |
| 725 | if r2.StatusCode != http.StatusOK { |
| 726 | t.Fatalf("re-claim: %d, want 200", r2.StatusCode) |
| 727 | } |
| 728 | b2 := decodeBody(t, r2) |
| 729 | if b2["host"] != host || b2["setup_link"] == nil { |
| 730 | t.Errorf("re-claim body = %v, want host + parked setup_link", b2) |
| 731 | } |
| 732 | } |
| 733 | |
| 734 | func TestClaimCreateGates(t *testing.T) { |
| 735 | srv, _, _, sess, _, _ := payFirstRig(t) |
| 736 | tok := mintCaller(t, srv, "home") |
| 737 | if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 738 | "requester": "user-1", "tier": "family", |
| 739 | "success_url": "https://h/", "cancel_url": "https://h/", |
| 740 | }); r.StatusCode != http.StatusOK { |
| 741 | t.Fatalf("checkout-create: %d", r.StatusCode) |
| 742 | } |
| 743 | |
| 744 | // Unpaid session → 402, nothing provisioned. |
| 745 | unpaid := paidSessionFor(1) |
| 746 | unpaid["payment_status"] = "unpaid" |
| 747 | *sess = unpaid |
| 748 | if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 749 | "requester": "user-1", "session_id": "cs_pf_1", |
| 750 | }); r.StatusCode != http.StatusPaymentRequired { |
| 751 | t.Errorf("unpaid claim: %d, want 402", r.StatusCode) |
| 752 | } |
| 753 | |
| 754 | // Someone else's requester → 404 (ownership is the pending row's pair). |
| 755 | *sess = paidSessionFor(1) |
| 756 | if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 757 | "requester": "someone-else", "session_id": "cs_pf_1", |
| 758 | }); r.StatusCode != http.StatusNotFound { |
| 759 | t.Errorf("wrong requester: %d, want 404", r.StatusCode) |
| 760 | } |
| 761 | |
| 762 | // A session without the pending_create marker (an upgrade checkout) can |
| 763 | // never provision — even fully paid. |
| 764 | upgrade := paidSessionFor(1) |
| 765 | upgrade["metadata"] = map[string]string{"tier": "family", "band": "base"} |
| 766 | *sess = upgrade |
| 767 | if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 768 | "requester": "user-1", "session_id": "cs_pf_1", |
| 769 | }); r.StatusCode != http.StatusNotFound { |
| 770 | t.Errorf("upgrade-session claim: %d, want 404", r.StatusCode) |
| 771 | } |
| 772 | } |
| 773 | |
| 774 | func TestClaimCreateRetriesAfterControlFailure(t *testing.T) { |
| 775 | srv, s, _, sess, _, ctlFails := payFirstRig(t) |
| 776 | tok := mintCaller(t, srv, "home") |
| 777 | if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{ |
| 778 | "requester": "user-1", "tier": "family", "name": "Book Club", |
| 779 | "success_url": "https://h/", "cancel_url": "https://h/", |
| 780 | }); r.StatusCode != http.StatusOK { |
| 781 | t.Fatalf("checkout-create: %d", r.StatusCode) |
| 782 | } |
| 783 | *sess = paidSessionFor(1) |
| 784 | |
| 785 | // First claim hits a control failure: 502, but the PAID row survives — |
| 786 | // money is never rolled back. |
| 787 | *ctlFails = 1 |
| 788 | if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 789 | "requester": "user-1", "session_id": "cs_pf_1", |
| 790 | }); r.StatusCode != http.StatusBadGateway { |
| 791 | t.Fatalf("claim with failing control: %d, want 502", r.StatusCode) |
| 792 | } |
| 793 | row, ok, _ := s.db.pendingSpaceByID(1) |
| 794 | if !ok || row.Entitlement != entPaid || row.Host != "" { |
| 795 | t.Fatalf("row after failed claim = %+v (ok=%v), want kept, paid, host-less", row, ok) |
| 796 | } |
| 797 | |
| 798 | // The retry finishes the job. |
| 799 | if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{ |
| 800 | "requester": "user-1", "session_id": "cs_pf_1", |
| 801 | }); r.StatusCode != http.StatusCreated { |
| 802 | t.Errorf("retry claim: %d, want 201", r.StatusCode) |
| 803 | } |
| 804 | } |
| 805 | |
| 806 | func TestPrunePendingCreates(t *testing.T) { |
| 807 | _, s, _, _, _, _ := payFirstRig(t) |
| 808 | old := time.Now().Add(-72 * time.Hour).Unix() |
| 809 | |
| 810 | stale, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Stale") |
| 811 | if err != nil { |
| 812 | t.Fatal(err) |
| 813 | } |
| 814 | paidStuck, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Paid but unclaimed") |
| 815 | if err != nil { |
| 816 | t.Fatal(err) |
| 817 | } |
| 818 | if err := s.db.markSpacePaid(paidStuck, "cus_1", "sub_1", "family", ""); err != nil { |
| 819 | t.Fatal(err) |
| 820 | } |
| 821 | fresh, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Fresh") |
| 822 | if err != nil { |
| 823 | t.Fatal(err) |
| 824 | } |
| 825 | // Backdate the first two past the cutoff. |
| 826 | for _, id := range []int64{stale, paidStuck} { |
| 827 | if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = ? WHERE id = ?`, old, id); err != nil { |
| 828 | t.Fatal(err) |
| 829 | } |
| 830 | } |
| 831 | |
| 832 | n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()) |
| 833 | if err != nil { |
| 834 | t.Fatal(err) |
| 835 | } |
| 836 | if n != 1 { |
| 837 | t.Errorf("pruned %d, want 1 (only the stale pending row)", n) |
| 838 | } |
| 839 | if _, ok, _ := s.db.pendingSpaceByID(stale); ok { |
| 840 | t.Error("stale pending row survived the prune") |
| 841 | } |
| 842 | // A paid-but-unclaimed row is money — never pruned; a fresh pending stays. |
| 843 | if _, ok, _ := s.db.pendingSpaceByID(paidStuck); !ok { |
| 844 | t.Error("paid row was pruned") |
| 845 | } |
| 846 | if _, ok, _ := s.db.pendingSpaceByID(fresh); !ok { |
| 847 | t.Error("fresh pending row was pruned") |
| 848 | } |
| 849 | } |
| 850 | |
| 851 | // ---- destroy cancels the subscription ------------------------------------------ |
| 852 | |
| 853 | // destroyRig builds a cloud whose control answers create + delete and whose |
| 854 | // Stripe stub records subscription cancels, answering each with the given |
| 855 | // status/body. Returns the cancelled ids and a count of control-side deletes, |
| 856 | // so tests can assert both "Stripe was told" and "nothing was deprovisioned". |
| 857 | func destroyRig(t *testing.T, cancelStatus int, cancelBody map[string]any) (srv *httptest.Server, s *cloudServer, canceled *[]string, ctlDeletes *int) { |
| 858 | t.Helper() |
| 859 | canceled = &[]string{} |
| 860 | ctlDeletes = new(int) |
| 861 | stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 862 | if r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions/") { |
| 863 | *canceled = append(*canceled, strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/")) |
| 864 | writeJSON(w, cancelStatus, cancelBody) |
| 865 | return |
| 866 | } |
| 867 | w.WriteHeader(http.StatusNotFound) |
| 868 | })) |
| 869 | t.Cleanup(stripe.Close) |
| 870 | ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| 871 | if r.Method == "DELETE" { |
| 872 | *ctlDeletes++ |
| 873 | writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| 874 | return |
| 875 | } |
| 876 | var req controlCreateReq |
| 877 | json.NewDecoder(r.Body).Decode(&req) |
| 878 | writeJSON(w, http.StatusCreated, map[string]any{"host": req.Host, "url": "https://" + req.Host}) |
| 879 | }) |
| 880 | srv, s = newTestCloud(t, ctl) |
| 881 | s.billing = &stripeBilling{ |
| 882 | apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", |
| 883 | tiers: []billingTier{{Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"}}, |
| 884 | client: http.DefaultClient, |
| 885 | } |
| 886 | return srv, s, canceled, ctlDeletes |
| 887 | } |
| 888 | |
| 889 | // deleteSpaceReq issues the owner-facing DELETE /api/instances/{host}. |
| 890 | func deleteSpaceReq(t *testing.T, srv *httptest.Server, token, host, requester string) *http.Response { |
| 891 | t.Helper() |
| 892 | req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+host+"?requester="+requester, nil) |
| 893 | req.Header.Set("Authorization", "Bearer "+token) |
| 894 | resp, err := http.DefaultClient.Do(req) |
| 895 | if err != nil { |
| 896 | t.Fatal(err) |
| 897 | } |
| 898 | t.Cleanup(func() { resp.Body.Close() }) |
| 899 | return resp |
| 900 | } |
| 901 | |
| 902 | // paidSpace creates a space as (tok, requester) and marks it paid with sub_x. |
| 903 | func paidSpace(t *testing.T, srv *httptest.Server, s *cloudServer, tok string) (host string, id int64) { |
| 904 | t.Helper() |
| 905 | host, _ = decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 906 | label, _, _ := strings.Cut(host, ".") |
| 907 | id, _ = spaceID(label) |
| 908 | if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { |
| 909 | t.Fatal(err) |
| 910 | } |
| 911 | return host, id |
| 912 | } |
| 913 | |
| 914 | func TestDestroyCancelsSubscription(t *testing.T) { |
| 915 | srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"id": "sub_x", "status": "canceled"}) |
| 916 | tok := mintCaller(t, srv, "home") |
| 917 | host, _ := paidSpace(t, srv, s, tok) |
| 918 | |
| 919 | if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| 920 | t.Fatalf("destroy: %d, want 200", r.StatusCode) |
| 921 | } |
| 922 | if len(*canceled) != 1 || (*canceled)[0] != "sub_x" { |
| 923 | t.Errorf("canceled = %v, want [sub_x]", *canceled) |
| 924 | } |
| 925 | if *ctlDeletes != 1 { |
| 926 | t.Errorf("control deletes = %d, want 1", *ctlDeletes) |
| 927 | } |
| 928 | if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 0 { |
| 929 | t.Errorf("spaces after destroy = %v (%v), want none", spaces, err) |
| 930 | } |
| 931 | } |
| 932 | |
| 933 | func TestDestroyBlockedWhenCancelFails(t *testing.T) { |
| 934 | srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusInternalServerError, |
| 935 | map[string]any{"error": map[string]any{"code": "api_error", "message": "boom"}}) |
| 936 | tok := mintCaller(t, srv, "home") |
| 937 | host, _ := paidSpace(t, srv, s, tok) |
| 938 | |
| 939 | // The cancel failed → the whole destroy refuses: no deprovision, ledger row |
| 940 | // kept, so a retry still knows the subscription. |
| 941 | if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusBadGateway { |
| 942 | t.Fatalf("destroy with failing cancel: %d, want 502", r.StatusCode) |
| 943 | } |
| 944 | if len(*canceled) != 1 { |
| 945 | t.Errorf("cancel attempts = %d, want 1", len(*canceled)) |
| 946 | } |
| 947 | if *ctlDeletes != 0 { |
| 948 | t.Errorf("control deletes = %d, want 0 (nothing deprovisioned)", *ctlDeletes) |
| 949 | } |
| 950 | if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 1 { |
| 951 | t.Errorf("spaces after refused destroy = %v (%v), want the row kept", spaces, err) |
| 952 | } |
| 953 | } |
| 954 | |
| 955 | func TestDestroyToleratesGoneSubscription(t *testing.T) { |
| 956 | // Stripe no longer knows the subscription (resource_missing) — the goal is |
| 957 | // "not billing", so the destroy proceeds. |
| 958 | srv, s, canceled, _ := destroyRig(t, http.StatusNotFound, |
| 959 | map[string]any{"error": map[string]any{"code": "resource_missing", "message": "No such subscription"}}) |
| 960 | tok := mintCaller(t, srv, "home") |
| 961 | host, _ := paidSpace(t, srv, s, tok) |
| 962 | |
| 963 | if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| 964 | t.Fatalf("destroy: %d, want 200", r.StatusCode) |
| 965 | } |
| 966 | if len(*canceled) != 1 { |
| 967 | t.Errorf("cancel attempts = %d, want 1", len(*canceled)) |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | func TestDestroySkipsStripeWhenNotPaid(t *testing.T) { |
| 972 | srv, s, canceled, _ := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) |
| 973 | tok := mintCaller(t, srv, "home") |
| 974 | |
| 975 | // A free space: no subscription, no Stripe call. |
| 976 | host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| 977 | if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| 978 | t.Fatalf("destroy free: %d, want 200", r.StatusCode) |
| 979 | } |
| 980 | if len(*canceled) != 0 { |
| 981 | t.Errorf("canceled = %v, want none for a free space", *canceled) |
| 982 | } |
| 983 | |
| 984 | // A lapsed space keeps its old subscription id but is entitlement-free — |
| 985 | // re-cancelling a canceled subscription is a Stripe error, so the gate is |
| 986 | // the entitlement, not the id. |
| 987 | host2, id2 := paidSpace(t, srv, s, tok) |
| 988 | if err := s.db.setEntitlementTier(id2, entFree, "", "", 0, 0, false); err != nil { |
| 989 | t.Fatal(err) |
| 990 | } |
| 991 | if r := deleteSpaceReq(t, srv, tok, host2, "user-1"); r.StatusCode != http.StatusOK { |
| 992 | t.Fatalf("destroy lapsed: %d, want 200", r.StatusCode) |
| 993 | } |
| 994 | if len(*canceled) != 0 { |
| 995 | t.Errorf("canceled = %v, want none for a lapsed space", *canceled) |
| 996 | } |
| 997 | } |
| 998 | |
| 999 | func TestDestroyPaidRefusesWithoutBilling(t *testing.T) { |
| 1000 | srv, s, _, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) |
| 1001 | tok := mintCaller(t, srv, "home") |
| 1002 | host, _ := paidSpace(t, srv, s, tok) |
| 1003 | |
| 1004 | // Billing unconfigured but the space has a live subscription: refusing is |
| 1005 | // the only move that doesn't leak a charge nobody can reach. |
| 1006 | s.billing = nil |
| 1007 | if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusConflict { |
| 1008 | t.Fatalf("destroy without billing: %d, want 409", r.StatusCode) |
| 1009 | } |
| 1010 | if *ctlDeletes != 0 { |
| 1011 | t.Errorf("control deletes = %d, want 0", *ctlDeletes) |
| 1012 | } |
| 1013 | } |
| 1014 | |