tenseleyflow/shithub / ee60ec8

Browse files

orgs tests: subscription-overwrite guard refuses second-sub, allows same-sub flip

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ee60ec8d04510bfb96b0e9b301db243560fa837f
Parents
5141417
Tree
83702fe

1 changed file

StatusFile+-
M internal/web/handlers/orgs/billing_webhook_guard_test.go 127 0
internal/web/handlers/orgs/billing_webhook_guard_test.gomodified
@@ -185,6 +185,133 @@ func TestBillingWebhookGuardRefusesTeamPriceOnUserSubject(t *testing.T) {
185185
 	}
186186
 }
187187
 
188
+// TestBillingWebhookGuardRefusesSecondSubscriptionForSameCustomer locks
189
+// PRO08 D3: when the principal already has a Stripe subscription on
190
+// file, a webhook event referencing a DIFFERENT subscription must be
191
+// refused. Pre-PRO08 it silently overwrote, orphaning the first sub.
192
+func TestBillingWebhookGuardRefusesSecondSubscriptionForSameCustomer(t *testing.T) {
193
+	t.Parallel()
194
+	ctx := context.Background()
195
+	pool := dbtest.NewTestDB(t)
196
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
197
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
198
+
199
+	// Seed the org with an existing subscription via direct apply.
200
+	if _, err := orgbilling.ApplySubscriptionSnapshot(ctx, orgbilling.Deps{Pool: pool}, orgbilling.SubscriptionSnapshot{
201
+		OrgID:                orgID,
202
+		Plan:                 orgbilling.PlanTeam,
203
+		Status:               orgbilling.SubscriptionStatusActive,
204
+		StripeSubscriptionID: "sub_FIRST",
205
+		LastWebhookEventID:   "evt_seed",
206
+	}); err != nil {
207
+		t.Fatalf("seed first sub: %v", err)
208
+	}
209
+
210
+	// Webhook now arrives for a SECOND subscription on the same org.
211
+	raw, err := json.Marshal(map[string]any{
212
+		"id":       "sub_SECOND",
213
+		"customer": "cus_overlap",
214
+		"status":   "active",
215
+		"metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
216
+		"items": map[string]any{"data": []map[string]any{{
217
+			"id":                   "si_second",
218
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
219
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
220
+			"price":                map[string]string{"id": testTeamPriceID},
221
+		}}},
222
+	})
223
+	if err != nil {
224
+		t.Fatalf("marshal: %v", err)
225
+	}
226
+	fake := &fakeStripeRemote{
227
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
228
+			return stripeapi.Event{
229
+				ID:   "evt_second_sub",
230
+				Type: stripeapi.EventType("customer.subscription.updated"),
231
+				Data: &stripeapi.EventData{Raw: raw},
232
+			}, nil
233
+		},
234
+	}
235
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
236
+	resp := postBillingWebhook(t, mux, "evt_second_sub")
237
+	if resp.Code != http.StatusInternalServerError {
238
+		t.Fatalf("expected 500 (refuse + Stripe retry), got %d body=%s", resp.Code, resp.Body.String())
239
+	}
240
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
241
+	if err != nil {
242
+		t.Fatalf("get state: %v", err)
243
+	}
244
+	if state.StripeSubscriptionID.String != "sub_FIRST" {
245
+		t.Fatalf("original sub overwritten: got %q want sub_FIRST", state.StripeSubscriptionID.String)
246
+	}
247
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_second_sub")
248
+	if err != nil {
249
+		t.Fatalf("get receipt: %v", err)
250
+	}
251
+	if !strings.Contains(receipt.ProcessError, "already bound to subscription") {
252
+		t.Errorf("expected overwrite-refusal error, got %q", receipt.ProcessError)
253
+	}
254
+}
255
+
256
+// TestBillingWebhookGuardAllowsSameSubscriptionUpdate confirms the
257
+// guard doesn't false-positive on the common case: subscription.updated
258
+// for the SAME subscription id (e.g., status flip from active →
259
+// past_due).
260
+func TestBillingWebhookGuardAllowsSameSubscriptionUpdate(t *testing.T) {
261
+	t.Parallel()
262
+	ctx := context.Background()
263
+	pool := dbtest.NewTestDB(t)
264
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
265
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
266
+
267
+	if _, err := orgbilling.ApplySubscriptionSnapshot(ctx, orgbilling.Deps{Pool: pool}, orgbilling.SubscriptionSnapshot{
268
+		OrgID:                orgID,
269
+		Plan:                 orgbilling.PlanTeam,
270
+		Status:               orgbilling.SubscriptionStatusActive,
271
+		StripeSubscriptionID: "sub_same",
272
+		LastWebhookEventID:   "evt_seed_same",
273
+	}); err != nil {
274
+		t.Fatalf("seed: %v", err)
275
+	}
276
+
277
+	raw, err := json.Marshal(map[string]any{
278
+		"id":       "sub_same",
279
+		"customer": "cus_same",
280
+		"status":   "past_due",
281
+		"metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
282
+		"items": map[string]any{"data": []map[string]any{{
283
+			"id":                   "si_same",
284
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
285
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
286
+			"price":                map[string]string{"id": testTeamPriceID},
287
+		}}},
288
+	})
289
+	if err != nil {
290
+		t.Fatalf("marshal: %v", err)
291
+	}
292
+	fake := &fakeStripeRemote{
293
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
294
+			return stripeapi.Event{
295
+				ID:   "evt_same_sub",
296
+				Type: stripeapi.EventType("customer.subscription.updated"),
297
+				Data: &stripeapi.EventData{Raw: raw},
298
+			}, nil
299
+		},
300
+	}
301
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
302
+	resp := postBillingWebhook(t, mux, "evt_same_sub")
303
+	if resp.Code != http.StatusOK {
304
+		t.Fatalf("same-sub status flip should succeed, got %d body=%s", resp.Code, resp.Body.String())
305
+	}
306
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
307
+	if err != nil {
308
+		t.Fatalf("get: %v", err)
309
+	}
310
+	if state.SubscriptionStatus != orgbilling.SubscriptionStatusPastDue {
311
+		t.Fatalf("expected past_due, got %s", state.SubscriptionStatus)
312
+	}
313
+}
314
+
188315
 // TestBillingWebhookGuardAllowsCorrectKindPriceMatch is the happy-path
189316
 // sanity check: the right price for the right kind passes the guard.
190317
 func TestBillingWebhookGuardAllowsCorrectKindPriceMatch(t *testing.T) {