tenseleyflow/shithub / 0f243b6

Browse files

web/handlers/profile: enforce Pro profile-pin cap with operator flag

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0f243b649e391ad9228db93df7b74a5879f9b274
Parents
2626b2a
Tree
0147c6c

4 changed files

StatusFile+-
M internal/web/handlers/profile/pins.go 65 4
A internal/web/handlers/profile/pins_enforce_test.go 141 0
M internal/web/handlers/profile/profile.go 7 0
M internal/web/handlers/profile/profile_test.go 16 2
internal/web/handlers/profile/pins.gomodified
@@ -18,6 +18,8 @@ import (
1818
 
1919
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
2020
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
21
+	"github.com/tenseleyFlow/shithub/internal/billing"
22
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
2123
 	"github.com/tenseleyFlow/shithub/internal/orgs"
2224
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
2325
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -25,6 +27,9 @@ import (
2527
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2628
 )
2729
 
30
+// profilePinLimit is the org-side cap; PRO01 keeps orgs at gh's
31
+// visible 6. User-side caps are resolved per-principal via
32
+// entitlements (Free=6, Pro=100).
2833
 const profilePinLimit = 6
2934
 
3035
 var (
@@ -91,7 +96,7 @@ func (h *Handlers) updateOrgPins(w http.ResponseWriter, r *http.Request, orgID i
9196
 	}
9297
 
9398
 	candidates := h.publicOrgPinCandidates(ctx, org.ID, string(org.Slug))
94
-	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
99
+	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates, profilePinLimit)
95100
 	if err != nil {
96101
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
97102
 		return
@@ -126,7 +131,12 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam
126131
 	}
127132
 
128133
 	candidates := h.publicUserPinCandidates(ctx, user.ID)
129
-	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates)
134
+	maxPins, beyondFreeAllowed := h.userProfilePinCap(ctx, user.ID)
135
+	repoIDs, err := selectedProfilePinIDs(r.PostForm["repo_id"], candidates, maxPins)
136
+	if errors.Is(err, errTooManyPins) {
137
+		h.handleUserPinOverflow(w, r, user.ID, beyondFreeAllowed)
138
+		return
139
+	}
130140
 	if err != nil {
131141
 		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
132142
 		return
@@ -139,6 +149,57 @@ func (h *Handlers) updateUserPins(w http.ResponseWriter, r *http.Request, rawNam
139149
 	http.Redirect(w, r, "/"+url.PathEscape(user.Username)+"#pinned", http.StatusSeeOther)
140150
 }
141151
 
152
+// userProfilePinCap resolves the entitled pin cap for a user. Returns
153
+// (cap, beyondFreeAllowed). Falls back to the Free cap if entitlement
154
+// resolution errors so a degraded billing path can't silently raise
155
+// the limit. beyondFreeAllowed mirrors CanUse(FeatureProfilePinsBeyondFree)
156
+// — the caller uses it to flavor the upgrade-banner copy and tells
157
+// the report-only logger whether the deny would have triggered.
158
+func (h *Handlers) userProfilePinCap(ctx context.Context, userID int64) (int64, bool) {
159
+	set, err := entitlements.ForPrincipal(ctx, entitlements.Deps{Pool: h.d.Pool}, billing.PrincipalForUser(userID))
160
+	if err != nil {
161
+		h.d.Logger.WarnContext(ctx, "profile pins: load entitlements", "user_id", userID, "error", err)
162
+		return entitlements.FreeProfilePinsCap, false
163
+	}
164
+	decision := set.CanUse(entitlements.FeatureProfilePinsBeyondFree)
165
+	if decision.Allowed {
166
+		return entitlements.ProProfilePinsCap, true
167
+	}
168
+	return entitlements.FreeProfilePinsCap, false
169
+}
170
+
171
+// handleUserPinOverflow renders the over-cap response for a personal
172
+// profile pin submission. When the operator hasn't flipped the enforce
173
+// flag (PRO05 report-only default), logs the would-deny and falls back
174
+// to the pre-PRO07 400 response. With enforce on, returns 402 + an
175
+// upgrade banner pointing at /settings/billing.
176
+func (h *Handlers) handleUserPinOverflow(w http.ResponseWriter, r *http.Request, userID int64, beyondFreeAllowed bool) {
177
+	// Pro users that hit this branch are over the Pro cap (100) — that's
178
+	// a DB-sanity 400 regardless of enforcement.
179
+	if beyondFreeAllowed {
180
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
181
+		return
182
+	}
183
+	if !h.d.BillingEnforce.UserProfilePinsBeyondFree {
184
+		h.d.Logger.InfoContext(r.Context(), "entitlements.report_only_deny",
185
+			"principal", billing.PrincipalForUser(userID).String(),
186
+			"principal_kind", string(billing.SubjectKindUser),
187
+			"principal_id", userID,
188
+			"feature", string(entitlements.FeatureProfilePinsBeyondFree),
189
+			"reason", string(entitlements.ReasonUpgradeRequired),
190
+			"required_plan", "pro")
191
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
192
+		return
193
+	}
194
+	decision := entitlements.Decision{
195
+		Feature:      entitlements.FeatureProfilePinsBeyondFree,
196
+		RequiredPlan: billing.Plan("pro"),
197
+		Reason:       entitlements.ReasonUpgradeRequired,
198
+	}
199
+	banner := decision.PrincipalUpgradeBanner("Pinned repositories", billing.PrincipalForUser(userID), "")
200
+	http.Error(w, banner.Message, banner.StatusCode)
201
+}
202
+
142203
 func (h *Handlers) orgPinData(ctx context.Context, orgID int64, orgSlug string, repos []orgProfileRepo) ([]orgProfileRepo, []profilePinCandidate) {
143204
 	publicRepos := publicOrgProfileRepos(repos)
144205
 	pinned := pinnedOrgRepos(publicRepos)
@@ -244,7 +305,7 @@ func (h *Handlers) publicOrgPinCandidates(ctx context.Context, orgID int64, orgS
244305
 	return out
245306
 }
246307
 
247
-func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([]int64, error) {
308
+func selectedProfilePinIDs(values []string, candidates []profilePinCandidate, maxPins int64) ([]int64, error) {
248309
 	allowed := make(map[int64]struct{}, len(candidates))
249310
 	for _, candidate := range candidates {
250311
 		allowed[candidate.ID] = struct{}{}
@@ -265,7 +326,7 @@ func selectedProfilePinIDs(values []string, candidates []profilePinCandidate) ([
265326
 		seen[repoID] = struct{}{}
266327
 		out = append(out, repoID)
267328
 	}
268
-	if len(out) > profilePinLimit {
329
+	if int64(len(out)) > maxPins {
269330
 		return nil, errTooManyPins
270331
 	}
271332
 	return out, nil
internal/web/handlers/profile/pins_enforce_test.goadded
@@ -0,0 +1,141 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package profile_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"net/http"
9
+	"strconv"
10
+	"strings"
11
+	"testing"
12
+	"time"
13
+
14
+	"github.com/jackc/pgx/v5/pgtype"
15
+
16
+	billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/entitlements"
18
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
19
+)
20
+
21
+// PRO07 — profile pin cap enforcement.
22
+//
23
+// The cap is resolved per-principal via entitlements (Free=6, Pro=100).
24
+// Operators flip BillingEnforce.UserProfilePinsBeyondFree to make a
25
+// Free user's 7th-pin attempt return 402 with an upgrade banner;
26
+// otherwise the gate is report-only (logs the deny + returns the
27
+// pre-PRO07 400 BadRequest).
28
+
29
+func TestProfilePins_FreeUserBlockedAtCapWithEnforce(t *testing.T) {
30
+	t.Parallel()
31
+	env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
32
+		UserProfilePinsBeyondFree: true,
33
+	})
34
+	alice := env.insertUser(t, "freealice", "Alice", "")
35
+	repos := makeUserPinCandidates(t, env, alice.ID, 7)
36
+
37
+	resp := env.postPins(t, "/freealice/pins", alice, repos...)
38
+	if resp.StatusCode != http.StatusPaymentRequired {
39
+		body, _ := io.ReadAll(resp.Body)
40
+		t.Fatalf("Free user 7th pin: status=%d, want 402; body=%s", resp.StatusCode, body)
41
+	}
42
+	body, _ := io.ReadAll(resp.Body)
43
+	if !strings.Contains(string(body), "Pinned repositories") {
44
+		t.Errorf("upgrade banner missing feature label: %s", body)
45
+	}
46
+}
47
+
48
+func TestProfilePins_FreeUserReportOnlyWithoutEnforce(t *testing.T) {
49
+	t.Parallel()
50
+	env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
51
+		UserProfilePinsBeyondFree: false,
52
+	})
53
+	alice := env.insertUser(t, "softalice", "Alice", "")
54
+	repos := makeUserPinCandidates(t, env, alice.ID, 7)
55
+
56
+	resp := env.postPins(t, "/softalice/pins", alice, repos...)
57
+	// Report-only: the over-cap submission falls through to the same
58
+	// 400 the user got pre-PRO07. The would-deny lands in the logger.
59
+	if resp.StatusCode != http.StatusBadRequest {
60
+		t.Fatalf("report-only over-cap status=%d, want 400", resp.StatusCode)
61
+	}
62
+}
63
+
64
+func TestProfilePins_FreeUserUnderCapSavesWithEnforce(t *testing.T) {
65
+	t.Parallel()
66
+	env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
67
+		UserProfilePinsBeyondFree: true,
68
+	})
69
+	alice := env.insertUser(t, "fitalice", "Alice", "")
70
+	repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.FreeProfilePinsCap))
71
+
72
+	resp := env.postPins(t, "/fitalice/pins", alice, repos...)
73
+	if resp.StatusCode != http.StatusSeeOther {
74
+		body, _ := io.ReadAll(resp.Body)
75
+		t.Fatalf("Free user at-cap save: status=%d body=%s", resp.StatusCode, body)
76
+	}
77
+}
78
+
79
+func TestProfilePins_ProUserCanExceedFreeCap(t *testing.T) {
80
+	t.Parallel()
81
+	env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
82
+		UserProfilePinsBeyondFree: true,
83
+	})
84
+	alice := env.insertUser(t, "proalice", "Alice", "")
85
+	upgradeUserToProForTest(t, env, alice.ID)
86
+	repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.FreeProfilePinsCap)+3)
87
+
88
+	resp := env.postPins(t, "/proalice/pins", alice, repos...)
89
+	if resp.StatusCode != http.StatusSeeOther {
90
+		body, _ := io.ReadAll(resp.Body)
91
+		t.Fatalf("Pro user over-Free-cap save: status=%d body=%s", resp.StatusCode, body)
92
+	}
93
+}
94
+
95
+func TestProfilePins_ProUserBlockedAboveProCap(t *testing.T) {
96
+	t.Parallel()
97
+	env := setupProfileEnvWithBillingEnforce(t, config.EnforceConfig{
98
+		UserProfilePinsBeyondFree: true,
99
+	})
100
+	alice := env.insertUser(t, "manyalice", "Alice", "")
101
+	upgradeUserToProForTest(t, env, alice.ID)
102
+	repos := makeUserPinCandidates(t, env, alice.ID, int(entitlements.ProProfilePinsCap)+1)
103
+
104
+	resp := env.postPins(t, "/manyalice/pins", alice, repos...)
105
+	// Pro user over the Pro cap is a DB-sanity 400, NOT a 402 — the
106
+	// upgrade banner doesn't help here.
107
+	if resp.StatusCode != http.StatusBadRequest {
108
+		t.Fatalf("Pro user >100 pins: status=%d, want 400", resp.StatusCode)
109
+	}
110
+}
111
+
112
+// makeUserPinCandidates inserts `count` public user-owned repos and
113
+// returns their IDs. Names are r0, r1, ... so the test can keep them
114
+// distinct without colliding with any other fixture.
115
+func makeUserPinCandidates(t *testing.T, env *profileEnv, userID int64, count int) []int64 {
116
+	t.Helper()
117
+	out := make([]int64, 0, count)
118
+	for i := 0; i < count; i++ {
119
+		id := env.insertUserRepo(t, userID, "r"+strconv.Itoa(i), "pin", "public", "Go", 0, 0)
120
+		out = append(out, id)
121
+	}
122
+	return out
123
+}
124
+
125
+func upgradeUserToProForTest(t *testing.T, env *profileEnv, userID int64) {
126
+	t.Helper()
127
+	now := time.Now().UTC()
128
+	suffix := strconv.FormatInt(userID, 10)
129
+	_, err := billingdb.New().ApplyUserSubscriptionSnapshot(context.Background(), env.pool, billingdb.ApplyUserSubscriptionSnapshotParams{
130
+		UserID:               userID,
131
+		Plan:                 billingdb.UserPlanPro,
132
+		SubscriptionStatus:   billingdb.BillingSubscriptionStatusActive,
133
+		StripeSubscriptionID: pgtype.Text{String: "sub_pin_pro_" + suffix, Valid: true},
134
+		CurrentPeriodStart:   pgtype.Timestamptz{Time: now.Add(-time.Hour), Valid: true},
135
+		CurrentPeriodEnd:     pgtype.Timestamptz{Time: now.Add(30 * 24 * time.Hour), Valid: true},
136
+		LastWebhookEventID:   "evt_pin_pro_" + suffix,
137
+	})
138
+	if err != nil {
139
+		t.Fatalf("upgrade user to Pro: %v", err)
140
+	}
141
+}
internal/web/handlers/profile/profile.gomodified
@@ -29,6 +29,7 @@ import (
2929
 	"github.com/tenseleyFlow/shithub/internal/auth/audit"
3030
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
3131
 	"github.com/tenseleyFlow/shithub/internal/avatars"
32
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
3233
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
3334
 	"github.com/tenseleyFlow/shithub/internal/orgs"
3435
 	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
@@ -50,6 +51,12 @@ type Deps struct {
5051
 	ObjectStore storage.ObjectStore
5152
 	Limiter     *throttle.Limiter
5253
 	Audit       *audit.Recorder
54
+	// BillingEnforce carries PRO07's per-feature enforcement flags.
55
+	// Zero value (all false) keeps profile-pin gating in report-only mode:
56
+	// Free users over the cap continue to hit a generic 400, the would-deny
57
+	// is logged for the soak. When UserProfilePinsBeyondFree flips true
58
+	// the same overflow returns 402 with an upgrade banner.
59
+	BillingEnforce config.EnforceConfig
5360
 }
5461
 
5562
 // Handlers is the registered profile handler set.
internal/web/handlers/profile/profile_test.gomodified
@@ -21,6 +21,7 @@ import (
2121
 	"github.com/jackc/pgx/v5/pgxpool"
2222
 
2323
 	authpkg "github.com/tenseleyFlow/shithub/internal/auth"
24
+	"github.com/tenseleyFlow/shithub/internal/infra/config"
2425
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2526
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
2627
 	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
@@ -54,7 +55,19 @@ func setupProfileEnvWithRepoFS(t *testing.T) *profileEnv {
5455
 	return setupProfileEnvWithDeps(t, nil, repoFS)
5556
 }
5657
 
58
+// setupProfileEnvWithBillingEnforce is the PRO07 entry point: same
59
+// scaffold as setupProfileEnv but with operator-configured per-feature
60
+// enforce flags. Zero-value enforce (all false) matches the default
61
+// helper and keeps report-only behavior.
62
+func setupProfileEnvWithBillingEnforce(t *testing.T, enforce config.EnforceConfig) *profileEnv {
63
+	return setupProfileEnvWithDepsAndEnforce(t, nil, nil, enforce)
64
+}
65
+
5766
 func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repoFS *storage.RepoFS) *profileEnv {
67
+	return setupProfileEnvWithDepsAndEnforce(t, objectStore, repoFS, config.EnforceConfig{})
68
+}
69
+
70
+func setupProfileEnvWithDepsAndEnforce(t *testing.T, objectStore storage.ObjectStore, repoFS *storage.RepoFS, enforce config.EnforceConfig) *profileEnv {
5871
 	t.Helper()
5972
 	pool := dbtest.NewTestDB(t)
6073
 
@@ -78,8 +91,9 @@ func setupProfileEnvWithDeps(t *testing.T, objectStore storage.ObjectStore, repo
7891
 	h, err := profileh.New(profileh.Deps{
7992
 		Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
8093
 		Render: rr, Pool: pool,
81
-		RepoFS:      repoFS,
82
-		ObjectStore: objectStore,
94
+		RepoFS:         repoFS,
95
+		ObjectStore:    objectStore,
96
+		BillingEnforce: enforce,
8397
 	})
8498
 	if err != nil {
8599
 		t.Fatalf("profileh.New: %v", err)