tenseleyflow/shithub / 9374798

Browse files

web/orgs tests: cross-kind webhook guard (empty-items, Pro-on-org, Team-on-user, happy)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9374798b5aa741e816f9f1858a25f437882ab244
Parents
443e03c
Tree
574b457

2 changed files

StatusFile+-
M internal/web/handlers/orgs/billing_test.go 16 0
A internal/web/handlers/orgs/billing_webhook_guard_test.go 233 0
internal/web/handlers/orgs/billing_test.gomodified
@@ -592,7 +592,21 @@ func newOrgBillingMux(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote st
592
 	return newOrgBillingMuxForUser(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, remote)
592
 	return newOrgBillingMuxForUser(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, remote)
593
 }
593
 }
594
 
594
 
595
+// newOrgBillingMuxWithPrices is the price-aware variant used by the
596
+// PRO08 cross-kind guard tests. Default mux leaves Team / Pro price
597
+// IDs empty (guard short-circuits); these tests need them populated
598
+// so the guard actually exercises its logic.
599
+func newOrgBillingMuxWithPrices(t *testing.T, pool *pgxpool.Pool, ownerID int64, remote stripebilling.Remote, teamPriceID, proPriceID string) *chi.Mux {
600
+	t.Helper()
601
+	return newOrgBillingMuxFull(t, pool, middleware.CurrentUser{ID: ownerID, Username: "owner"}, remote, teamPriceID, proPriceID)
602
+}
603
+
595
 func newOrgBillingMuxForUser(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, remote stripebilling.Remote) *chi.Mux {
604
 func newOrgBillingMuxForUser(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, remote stripebilling.Remote) *chi.Mux {
605
+	t.Helper()
606
+	return newOrgBillingMuxFull(t, pool, viewer, remote, "", "")
607
+}
608
+
609
+func newOrgBillingMuxFull(t *testing.T, pool *pgxpool.Pool, viewer middleware.CurrentUser, remote stripebilling.Remote, teamPriceID, proPriceID string) *chi.Mux {
596
 	t.Helper()
610
 	t.Helper()
597
 	tmplFS := fstest.MapFS{
611
 	tmplFS := fstest.MapFS{
598
 		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
612
 		"_layout.html":               {Data: []byte(`{{ define "layout" }}<html><body>{{ template "page" . }}</body></html>{{ end }}`)},
@@ -617,6 +631,8 @@ func newOrgBillingMuxForUser(t *testing.T, pool *pgxpool.Pool, viewer middleware
617
 		StripeSuccessURL:      "https://shithub.example/organizations/{org}/billing/success",
631
 		StripeSuccessURL:      "https://shithub.example/organizations/{org}/billing/success",
618
 		StripeCancelURL:       "https://shithub.example/organizations/{org}/billing/cancel",
632
 		StripeCancelURL:       "https://shithub.example/organizations/{org}/billing/cancel",
619
 		StripePortalReturnURL: "https://shithub.example/organizations/{org}/settings/billing",
633
 		StripePortalReturnURL: "https://shithub.example/organizations/{org}/settings/billing",
634
+		StripeTeamPriceID:     teamPriceID,
635
+		StripeProPriceID:      proPriceID,
620
 	})
636
 	})
621
 	if err != nil {
637
 	if err != nil {
622
 		t.Fatalf("orgsh.New: %v", err)
638
 		t.Fatalf("orgsh.New: %v", err)
internal/web/handlers/orgs/billing_webhook_guard_test.goadded
@@ -0,0 +1,233 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package orgs_test
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"net/http"
9
+	"strconv"
10
+	"strings"
11
+	"testing"
12
+	"time"
13
+
14
+	stripeapi "github.com/stripe/stripe-go/v85"
15
+
16
+	orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
17
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
19
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
20
+)
21
+
22
+// PRO08 cross-kind price guard tests.
23
+//
24
+// These lock guardPriceKindMatch behavior:
25
+//   - A1: empty items refused when prices are configured (else the guard
26
+//     can't run and a misrouted Pro-priced sub silently writes Team).
27
+//   - Pro price on org subject ⇒ refused, no state mutation.
28
+//   - Team price on user subject ⇒ refused.
29
+//   - Receipt row is marked failed with the guard's error message.
30
+
31
+const (
32
+	testTeamPriceID = "price_team_test"
33
+	testProPriceID  = "price_pro_test"
34
+)
35
+
36
+func TestBillingWebhookGuardRefusesEmptyItemsWhenPricesConfigured(t *testing.T) {
37
+	t.Parallel()
38
+	ctx := context.Background()
39
+	pool := dbtest.NewTestDB(t)
40
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
41
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
42
+	raw, err := json.Marshal(map[string]any{
43
+		"id":       "sub_empty_items",
44
+		"customer": "cus_empty_items",
45
+		"status":   "active",
46
+		"metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
47
+		// items field omitted entirely — Stripe rarely sends this but
48
+		// a malformed event MUST loud-fail rather than bypass the guard.
49
+	})
50
+	if err != nil {
51
+		t.Fatalf("marshal: %v", err)
52
+	}
53
+	fake := &fakeStripeRemote{
54
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
55
+			return stripeapi.Event{
56
+				ID:   "evt_empty_items",
57
+				Type: stripeapi.EventType("customer.subscription.updated"),
58
+				Data: &stripeapi.EventData{Raw: raw},
59
+			}, nil
60
+		},
61
+	}
62
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
63
+	resp := postBillingWebhook(t, mux, "evt_empty_items")
64
+	if resp.Code != http.StatusInternalServerError {
65
+		t.Fatalf("empty-items webhook status=%d body=%s", resp.Code, resp.Body.String())
66
+	}
67
+	// State must not have flipped.
68
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
69
+	if err != nil {
70
+		t.Fatalf("GetOrgBillingState: %v", err)
71
+	}
72
+	if state.Plan != orgbilling.PlanFree {
73
+		t.Fatalf("guarded webhook should leave org Free, got %s", state.Plan)
74
+	}
75
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_empty_items")
76
+	if err != nil {
77
+		t.Fatalf("GetWebhookEventReceipt: %v", err)
78
+	}
79
+	if receipt.ProcessError == "" || !strings.Contains(receipt.ProcessError, "no line items") {
80
+		t.Fatalf("expected guard failure receipt, got process_error=%q", receipt.ProcessError)
81
+	}
82
+}
83
+
84
+func TestBillingWebhookGuardRefusesProPriceOnOrgSubject(t *testing.T) {
85
+	t.Parallel()
86
+	ctx := context.Background()
87
+	pool := dbtest.NewTestDB(t)
88
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
89
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
90
+	raw, err := json.Marshal(map[string]any{
91
+		"id":       "sub_pro_on_org",
92
+		"customer": "cus_pro_on_org",
93
+		"status":   "active",
94
+		"metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
95
+		"items": map[string]any{"data": []map[string]any{{
96
+			"id":                   "si_pro_on_org",
97
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
98
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
99
+			"price":                map[string]string{"id": testProPriceID},
100
+		}}},
101
+	})
102
+	if err != nil {
103
+		t.Fatalf("marshal: %v", err)
104
+	}
105
+	fake := &fakeStripeRemote{
106
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
107
+			return stripeapi.Event{
108
+				ID:   "evt_pro_on_org",
109
+				Type: stripeapi.EventType("customer.subscription.updated"),
110
+				Data: &stripeapi.EventData{Raw: raw},
111
+			}, nil
112
+		},
113
+	}
114
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
115
+	resp := postBillingWebhook(t, mux, "evt_pro_on_org")
116
+	if resp.Code != http.StatusInternalServerError {
117
+		t.Fatalf("misroute status=%d body=%s", resp.Code, resp.Body.String())
118
+	}
119
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
120
+	if err != nil {
121
+		t.Fatalf("GetOrgBillingState: %v", err)
122
+	}
123
+	if state.Plan != orgbilling.PlanFree {
124
+		t.Fatalf("misroute should leave org Free, got %s", state.Plan)
125
+	}
126
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_pro_on_org")
127
+	if err != nil {
128
+		t.Fatalf("GetWebhookEventReceipt: %v", err)
129
+	}
130
+	if !strings.Contains(receipt.ProcessError, "Pro price") {
131
+		t.Fatalf("expected Pro-price-on-org error, got %q", receipt.ProcessError)
132
+	}
133
+}
134
+
135
+func TestBillingWebhookGuardRefusesTeamPriceOnUserSubject(t *testing.T) {
136
+	t.Parallel()
137
+	ctx := context.Background()
138
+	pool := dbtest.NewTestDB(t)
139
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
140
+	raw, err := json.Marshal(map[string]any{
141
+		"id":       "sub_team_on_user",
142
+		"customer": "cus_team_on_user",
143
+		"status":   "active",
144
+		"metadata": map[string]string{
145
+			stripebilling.MetadataSubjectKind: "user",
146
+			stripebilling.MetadataSubjectID:   strconv.FormatInt(ownerID, 10),
147
+		},
148
+		"items": map[string]any{"data": []map[string]any{{
149
+			"id":                   "si_team_on_user",
150
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
151
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
152
+			"price":                map[string]string{"id": testTeamPriceID},
153
+		}}},
154
+	})
155
+	if err != nil {
156
+		t.Fatalf("marshal: %v", err)
157
+	}
158
+	fake := &fakeStripeRemote{
159
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
160
+			return stripeapi.Event{
161
+				ID:   "evt_team_on_user",
162
+				Type: stripeapi.EventType("customer.subscription.updated"),
163
+				Data: &stripeapi.EventData{Raw: raw},
164
+			}, nil
165
+		},
166
+	}
167
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
168
+	resp := postBillingWebhook(t, mux, "evt_team_on_user")
169
+	if resp.Code != http.StatusInternalServerError {
170
+		t.Fatalf("misroute status=%d body=%s", resp.Code, resp.Body.String())
171
+	}
172
+	userState, err := orgbilling.GetUserBillingState(ctx, orgbilling.Deps{Pool: pool}, ownerID)
173
+	if err != nil {
174
+		t.Fatalf("GetUserBillingState: %v", err)
175
+	}
176
+	if userState.Plan != orgbilling.UserPlanFree {
177
+		t.Fatalf("misroute should leave user Free, got %s", userState.Plan)
178
+	}
179
+	receipt, err := billingdb.New().GetWebhookEventReceipt(ctx, pool, "evt_team_on_user")
180
+	if err != nil {
181
+		t.Fatalf("GetWebhookEventReceipt: %v", err)
182
+	}
183
+	if !strings.Contains(receipt.ProcessError, "Team price") {
184
+		t.Fatalf("expected Team-price-on-user error, got %q", receipt.ProcessError)
185
+	}
186
+}
187
+
188
+// TestBillingWebhookGuardAllowsCorrectKindPriceMatch is the happy-path
189
+// sanity check: the right price for the right kind passes the guard.
190
+func TestBillingWebhookGuardAllowsCorrectKindPriceMatch(t *testing.T) {
191
+	t.Parallel()
192
+	ctx := context.Background()
193
+	pool := dbtest.NewTestDB(t)
194
+	ownerID := insertOrgAvatarUser(t, pool, "owner")
195
+	orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme")
196
+	raw, err := json.Marshal(map[string]any{
197
+		"id":       "sub_team_on_org",
198
+		"customer": "cus_team_on_org",
199
+		"status":   "active",
200
+		"metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)},
201
+		"items": map[string]any{"data": []map[string]any{{
202
+			"id":                   "si_team_on_org",
203
+			"current_period_start": time.Now().UTC().Add(-time.Hour).Unix(),
204
+			"current_period_end":   time.Now().UTC().Add(30 * 24 * time.Hour).Unix(),
205
+			"price":                map[string]string{"id": testTeamPriceID},
206
+		}}},
207
+	})
208
+	if err != nil {
209
+		t.Fatalf("marshal: %v", err)
210
+	}
211
+	fake := &fakeStripeRemote{
212
+		verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) {
213
+			return stripeapi.Event{
214
+				ID:   "evt_team_on_org",
215
+				Type: stripeapi.EventType("customer.subscription.updated"),
216
+				Data: &stripeapi.EventData{Raw: raw},
217
+			}, nil
218
+		},
219
+	}
220
+	mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID)
221
+	resp := postBillingWebhook(t, mux, "evt_team_on_org")
222
+	if resp.Code != http.StatusOK {
223
+		t.Fatalf("happy-path status=%d body=%s", resp.Code, resp.Body.String())
224
+	}
225
+	state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID)
226
+	if err != nil {
227
+		t.Fatalf("GetOrgBillingState: %v", err)
228
+	}
229
+	if state.Plan != orgbilling.PlanTeam {
230
+		t.Fatalf("happy-path should apply Team plan, got %s", state.Plan)
231
+	}
232
+}
233
+