eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
21func 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.
44func 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.
63func 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.
84func 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.
93func 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
105func 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
145func 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
193func 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
217func 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
314func 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.
354func 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).
392func 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
405func 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.
431func 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).
468func 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.
505func 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.
565func 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.
609func 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.
624func 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
633func 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
687func 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
734func 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
774func 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
806func 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".
857func 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}.
890func 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.
903func 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
914func 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
933func 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
955func 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
971func 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
999func 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