Harden org entitlement decisions
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
2c82cd28286b8ac350cdc90e66541e39dc816ba4- Parents
-
fd6b7b3 - Tree
d9f0787
2c82cd2
2c82cd28286b8ac350cdc90e66541e39dc816ba4fd6b7b3
d9f0787| Status | File | + | - |
|---|---|---|---|
| M |
docs/internal/billing.md
|
24 | 0 |
| M |
docs/internal/permissions.md
|
7 | 0 |
| M |
internal/entitlements/entitlements.go
|
164 | 18 |
| M |
internal/entitlements/entitlements_test.go
|
135 | 31 |
docs/internal/billing.mdmodified@@ -189,6 +189,13 @@ as scattered `orgs.plan` checks in handlers. | ||
| 189 | 189 | store and scan the plan value, but product behavior should ask the |
| 190 | 190 | entitlement package whether a feature key is available. |
| 191 | 191 | |
| 192 | +The package entrypoint is `entitlements.ForOrg(ctx, deps, orgID)`, | |
| 193 | +which loads the local `org_billing_states` projection and returns a | |
| 194 | +request-scoped entitlement set. Callers then use `CanUse(feature)` for | |
| 195 | +feature decisions and `Limit(name)` for paid limit metadata. The legacy | |
| 196 | +`CheckOrgFeature` helper is a thin wrapper for handlers that need only | |
| 197 | +one feature. These calls are deterministic and never call Stripe. | |
| 198 | + | |
| 192 | 199 | Expected feature keys: |
| 193 | 200 | |
| 194 | 201 | - `org.secret_teams` |
@@ -205,6 +212,23 @@ the policy permission and the paid entitlement for gated writes. Denials | ||
| 205 | 212 | must preserve existing `policy.Maybe404` behavior where existence leaks |
| 206 | 213 | matter. |
| 207 | 214 | |
| 215 | +Entitlement outcomes are: | |
| 216 | + | |
| 217 | +- Free organizations receive `upgrade_required` for Team features. | |
| 218 | +- Team organizations with `active` or `trialing` subscriptions receive | |
| 219 | + the feature. | |
| 220 | +- Team organizations in `past_due` remain usable only while their local | |
| 221 | + grace window has not expired. | |
| 222 | +- Team organizations with incomplete, unpaid, paused, canceled, or | |
| 223 | + expired-grace billing receive `billing_action_needed`. | |
| 224 | +- Enterprise remains a contact-sales stub and receives | |
| 225 | + `enterprise_contact_sales` until a later Enterprise sprint defines a | |
| 226 | + real feature set. | |
| 227 | + | |
| 228 | +Handler upgrade helpers map paid-feature denials to HTTP 402 metadata | |
| 229 | +and a billing-settings path, but handlers must still perform normal | |
| 230 | +authorization first and preserve 404 masking for private resources. | |
| 231 | + | |
| 208 | 232 | ## Downgrade behavior |
| 209 | 233 | |
| 210 | 234 | Downgrades must preserve customer data. Moving from Team to Free should |
docs/internal/permissions.mdmodified@@ -131,6 +131,13 @@ That keeps security authorization independent from commercial product | ||
| 131 | 131 | packaging, and makes downgrades/grace periods possible without |
| 132 | 132 | rewriting role checks. |
| 133 | 133 | |
| 134 | +For paid-feature UI, handlers should use the entitlement decision's | |
| 135 | +upgrade metadata instead of inventing per-surface billing state. A | |
| 136 | +missing Team feature maps to HTTP 402 semantics and a billing-settings | |
| 137 | +path after the normal authorization result has already been accepted. | |
| 138 | +Enterprise is not implicitly Team-plus; it is a contact-sales | |
| 139 | +entitlement result until the Enterprise product contract ships. | |
| 140 | + | |
| 134 | 141 | ## Existence-leak guard |
| 135 | 142 | |
| 136 | 143 | `policy.Maybe404(decision, repo, actor)` maps a denial to a status |
internal/entitlements/entitlements.gomodified@@ -5,6 +5,8 @@ package entitlements | ||
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | 7 | "errors" |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 8 | 10 | "time" |
| 9 | 11 | |
| 10 | 12 | "github.com/jackc/pgx/v5/pgxpool" |
@@ -25,12 +27,22 @@ const ( | ||
| 25 | 27 | FeatureOrgActionsMinutesQuota Feature = "org.actions_minutes_quota" |
| 26 | 28 | ) |
| 27 | 29 | |
| 30 | +type Limit string | |
| 31 | + | |
| 32 | +const ( | |
| 33 | + LimitOrgPrivateCollaboration Limit = "org.private_collaboration_limit" | |
| 34 | + LimitOrgStorageQuota Limit = "org.storage_quota" | |
| 35 | + LimitOrgActionsMinutesQuota Limit = "org.actions_minutes_quota" | |
| 36 | +) | |
| 37 | + | |
| 28 | 38 | type Reason string |
| 29 | 39 | |
| 30 | 40 | const ( |
| 31 | - ReasonNone Reason = "" | |
| 32 | - ReasonUpgradeRequired Reason = "upgrade_required" | |
| 33 | - ReasonBillingActionNeeded Reason = "billing_action_needed" | |
| 41 | + ReasonNone Reason = "" | |
| 42 | + ReasonUpgradeRequired Reason = "upgrade_required" | |
| 43 | + ReasonBillingActionNeeded Reason = "billing_action_needed" | |
| 44 | + ReasonEnterpriseContactSales Reason = "enterprise_contact_sales" | |
| 45 | + ReasonUnknownFeature Reason = "unknown_feature" | |
| 34 | 46 | ) |
| 35 | 47 | |
| 36 | 48 | type Deps struct { |
@@ -45,31 +57,150 @@ type Decision struct { | ||
| 45 | 57 | Reason Reason |
| 46 | 58 | } |
| 47 | 59 | |
| 60 | +type LimitValue struct { | |
| 61 | + Name Limit | |
| 62 | + Feature Feature | |
| 63 | + Allowed bool | |
| 64 | + Defined bool | |
| 65 | + Unlimited bool | |
| 66 | + Value int64 | |
| 67 | + Unit string | |
| 68 | + RequiredPlan billing.Plan | |
| 69 | + Reason Reason | |
| 70 | +} | |
| 71 | + | |
| 72 | +type UpgradeBanner struct { | |
| 73 | + Message string | |
| 74 | + ActionText string | |
| 75 | + ActionHref string | |
| 76 | + StatusCode int | |
| 77 | +} | |
| 78 | + | |
| 79 | +type Loader struct { | |
| 80 | + deps Deps | |
| 81 | +} | |
| 82 | + | |
| 83 | +type Set struct { | |
| 84 | + OrgID int64 | |
| 85 | + State billing.State | |
| 86 | + now time.Time | |
| 87 | +} | |
| 88 | + | |
| 48 | 89 | var ( |
| 49 | 90 | ErrPoolRequired = errors.New("entitlements: pool is required") |
| 50 | 91 | ErrOrgIDRequired = errors.New("entitlements: org id is required") |
| 51 | 92 | ErrUnknownFeature = errors.New("entitlements: unknown feature") |
| 93 | + ErrUnknownLimit = errors.New("entitlements: unknown limit") | |
| 52 | 94 | ) |
| 53 | 95 | |
| 54 | -func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) { | |
| 55 | - if deps.Pool == nil { | |
| 56 | - return Decision{}, ErrPoolRequired | |
| 96 | +func New(deps Deps) Loader { | |
| 97 | + return Loader{deps: deps} | |
| 98 | +} | |
| 99 | + | |
| 100 | +func ForOrg(ctx context.Context, deps Deps, orgID int64) (Set, error) { | |
| 101 | + return New(deps).ForOrg(ctx, orgID) | |
| 102 | +} | |
| 103 | + | |
| 104 | +func (l Loader) ForOrg(ctx context.Context, orgID int64) (Set, error) { | |
| 105 | + if l.deps.Pool == nil { | |
| 106 | + return Set{}, ErrPoolRequired | |
| 57 | 107 | } |
| 58 | 108 | if orgID == 0 { |
| 59 | - return Decision{}, ErrOrgIDRequired | |
| 109 | + return Set{}, ErrOrgIDRequired | |
| 110 | + } | |
| 111 | + state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: l.deps.Pool}, orgID) | |
| 112 | + if err != nil { | |
| 113 | + return Set{}, err | |
| 114 | + } | |
| 115 | + now := time.Now().UTC() | |
| 116 | + if l.deps.Now != nil { | |
| 117 | + now = l.deps.Now().UTC() | |
| 60 | 118 | } |
| 61 | - if requiredPlanForFeature(feature) == "" { | |
| 119 | + return Set{OrgID: orgID, State: state, now: now}, nil | |
| 120 | +} | |
| 121 | + | |
| 122 | +func CheckOrgFeature(ctx context.Context, deps Deps, orgID int64, feature Feature) (Decision, error) { | |
| 123 | + if !KnownFeature(feature) { | |
| 62 | 124 | return Decision{}, ErrUnknownFeature |
| 63 | 125 | } |
| 64 | - state, err := billing.GetOrgBillingState(ctx, billing.Deps{Pool: deps.Pool}, orgID) | |
| 126 | + set, err := ForOrg(ctx, deps, orgID) | |
| 65 | 127 | if err != nil { |
| 66 | 128 | return Decision{}, err |
| 67 | 129 | } |
| 68 | - now := time.Now().UTC() | |
| 69 | - if deps.Now != nil { | |
| 70 | - now = deps.Now().UTC() | |
| 130 | + return set.CanUse(feature), nil | |
| 131 | +} | |
| 132 | + | |
| 133 | +func (s Set) CanUse(feature Feature) Decision { | |
| 134 | + return decideFeature(s.now, s.State, feature) | |
| 135 | +} | |
| 136 | + | |
| 137 | +func (s Set) Limit(name Limit) (LimitValue, error) { | |
| 138 | + feature, unit, ok := limitFeature(name) | |
| 139 | + if !ok { | |
| 140 | + return LimitValue{ | |
| 141 | + Name: name, | |
| 142 | + Reason: ReasonUnknownFeature, | |
| 143 | + }, ErrUnknownLimit | |
| 144 | + } | |
| 145 | + decision := s.CanUse(feature) | |
| 146 | + value := LimitValue{ | |
| 147 | + Name: name, | |
| 148 | + Feature: feature, | |
| 149 | + Allowed: decision.Allowed, | |
| 150 | + Unit: unit, | |
| 151 | + RequiredPlan: decision.RequiredPlan, | |
| 152 | + Reason: decision.Reason, | |
| 153 | + } | |
| 154 | + if !decision.Allowed { | |
| 155 | + return value, nil | |
| 156 | + } | |
| 157 | + switch name { | |
| 158 | + case LimitOrgPrivateCollaboration: | |
| 159 | + value.Defined = true | |
| 160 | + value.Unlimited = true | |
| 161 | + case LimitOrgStorageQuota, LimitOrgActionsMinutesQuota: | |
| 162 | + // SP08 owns usage accounting and concrete quota numbers. Until | |
| 163 | + // then, expose entitlement state without pretending metering is enforced. | |
| 164 | + value.Defined = false | |
| 165 | + } | |
| 166 | + return value, nil | |
| 167 | +} | |
| 168 | + | |
| 169 | +func KnownFeature(feature Feature) bool { | |
| 170 | + return requiredPlanForFeature(feature) != "" | |
| 171 | +} | |
| 172 | + | |
| 173 | +func KnownLimit(name Limit) bool { | |
| 174 | + _, _, ok := limitFeature(name) | |
| 175 | + return ok | |
| 176 | +} | |
| 177 | + | |
| 178 | +func (d Decision) HTTPStatus() int { | |
| 179 | + if d.Allowed { | |
| 180 | + return http.StatusOK | |
| 181 | + } | |
| 182 | + return http.StatusPaymentRequired | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (d Decision) BillingPath(orgSlug string) string { | |
| 186 | + return "/organizations/" + url.PathEscape(orgSlug) + "/settings/billing" | |
| 187 | +} | |
| 188 | + | |
| 189 | +func (d Decision) UpgradeBanner(label, orgSlug string) UpgradeBanner { | |
| 190 | + banner := UpgradeBanner{ | |
| 191 | + ActionText: "Manage billing and plans", | |
| 192 | + ActionHref: d.BillingPath(orgSlug), | |
| 193 | + StatusCode: d.HTTPStatus(), | |
| 194 | + } | |
| 195 | + switch d.Reason { | |
| 196 | + case ReasonBillingActionNeeded: | |
| 197 | + banner.Message = label + " are read-only until Team billing is brought back into good standing." | |
| 198 | + case ReasonEnterpriseContactSales: | |
| 199 | + banner.Message = label + " require a supported enterprise plan. Contact sales to continue." | |
| 200 | + default: | |
| 201 | + banner.Message = label + " require Team billing. Upgrade this organization to continue." | |
| 71 | 202 | } |
| 72 | - return decideFeature(now, state, feature), nil | |
| 203 | + return banner | |
| 73 | 204 | } |
| 74 | 205 | |
| 75 | 206 | func requiredPlanForFeature(feature Feature) billing.Plan { |
@@ -88,27 +219,42 @@ func requiredPlanForFeature(feature Feature) billing.Plan { | ||
| 88 | 219 | } |
| 89 | 220 | } |
| 90 | 221 | |
| 222 | +func limitFeature(name Limit) (Feature, string, bool) { | |
| 223 | + switch name { | |
| 224 | + case LimitOrgPrivateCollaboration: | |
| 225 | + return FeatureOrgPrivateCollaboration, "collaborators", true | |
| 226 | + case LimitOrgStorageQuota: | |
| 227 | + return FeatureOrgStorageQuota, "bytes", true | |
| 228 | + case LimitOrgActionsMinutesQuota: | |
| 229 | + return FeatureOrgActionsMinutesQuota, "minutes", true | |
| 230 | + default: | |
| 231 | + return "", "", false | |
| 232 | + } | |
| 233 | +} | |
| 234 | + | |
| 91 | 235 | func decideFeature(now time.Time, state billing.State, feature Feature) Decision { |
| 92 | 236 | decision := Decision{ |
| 93 | 237 | Feature: feature, |
| 94 | 238 | RequiredPlan: requiredPlanForFeature(feature), |
| 95 | 239 | Reason: ReasonUpgradeRequired, |
| 96 | 240 | } |
| 241 | + if decision.RequiredPlan == "" { | |
| 242 | + decision.Reason = ReasonUnknownFeature | |
| 243 | + return decision | |
| 244 | + } | |
| 97 | 245 | switch state.Plan { |
| 98 | 246 | case billing.PlanEnterprise: |
| 99 | - decision.Allowed = true | |
| 100 | - decision.Reason = ReasonNone | |
| 247 | + decision.Reason = ReasonEnterpriseContactSales | |
| 101 | 248 | return decision |
| 102 | 249 | case billing.PlanTeam: |
| 103 | 250 | switch state.SubscriptionStatus { |
| 104 | 251 | case billing.SubscriptionStatusActive, |
| 105 | - billing.SubscriptionStatusTrialing, | |
| 106 | - billing.SubscriptionStatusIncomplete: | |
| 252 | + billing.SubscriptionStatusTrialing: | |
| 107 | 253 | decision.Allowed = true |
| 108 | 254 | decision.Reason = ReasonNone |
| 109 | 255 | return decision |
| 110 | 256 | case billing.SubscriptionStatusPastDue: |
| 111 | - if !state.GraceUntil.Valid || !now.After(state.GraceUntil.Time) { | |
| 257 | + if state.GraceUntil.Valid && !now.After(state.GraceUntil.Time) { | |
| 112 | 258 | decision.Allowed = true |
| 113 | 259 | decision.Reason = ReasonNone |
| 114 | 260 | return decision |
internal/entitlements/entitlements_test.gomodified@@ -4,8 +4,11 @@ package entitlements_test | ||
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | 6 | "context" |
| 7 | + "errors" | |
| 7 | 8 | "io" |
| 8 | 9 | "log/slog" |
| 10 | + "net/http" | |
| 11 | + "strings" | |
| 9 | 12 | "testing" |
| 10 | 13 | "time" |
| 11 | 14 | |
@@ -39,34 +42,31 @@ func TestCheckOrgFeature(t *testing.T) { | ||
| 39 | 42 | { |
| 40 | 43 | name: "team active allows feature", |
| 41 | 44 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 42 | - _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | |
| 43 | - OrgID: orgID, | |
| 44 | - Plan: billing.PlanTeam, | |
| 45 | - Status: billing.SubscriptionStatusActive, | |
| 46 | - StripeSubscriptionID: "sub_active", | |
| 47 | - StripeSubscriptionItemID: "si_active", | |
| 48 | - CurrentPeriodStart: now, | |
| 49 | - CurrentPeriodEnd: now.Add(30 * 24 * time.Hour), | |
| 50 | - LastWebhookEventID: "evt_active", | |
| 51 | - }) | |
| 52 | - return err | |
| 45 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "active") | |
| 46 | + }, | |
| 47 | + want: true, | |
| 48 | + reason: entitlements.ReasonNone, | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + name: "team trialing allows feature", | |
| 52 | + mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | |
| 53 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusTrialing, "trialing") | |
| 53 | 54 | }, |
| 54 | 55 | want: true, |
| 55 | 56 | reason: entitlements.ReasonNone, |
| 56 | 57 | }, |
| 58 | + { | |
| 59 | + name: "team incomplete needs billing action", | |
| 60 | + mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | |
| 61 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusIncomplete, "incomplete") | |
| 62 | + }, | |
| 63 | + want: false, | |
| 64 | + reason: entitlements.ReasonBillingActionNeeded, | |
| 65 | + }, | |
| 57 | 66 | { |
| 58 | 67 | name: "team past due within grace still allows feature", |
| 59 | 68 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 60 | - if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | |
| 61 | - OrgID: orgID, | |
| 62 | - Plan: billing.PlanTeam, | |
| 63 | - Status: billing.SubscriptionStatusActive, | |
| 64 | - StripeSubscriptionID: "sub_grace", | |
| 65 | - StripeSubscriptionItemID: "si_grace", | |
| 66 | - CurrentPeriodStart: now, | |
| 67 | - CurrentPeriodEnd: now.Add(30 * 24 * time.Hour), | |
| 68 | - LastWebhookEventID: "evt_active", | |
| 69 | - }); err != nil { | |
| 69 | + if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "grace"); err != nil { | |
| 70 | 70 | return err |
| 71 | 71 | } |
| 72 | 72 | _, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due") |
@@ -79,16 +79,7 @@ func TestCheckOrgFeature(t *testing.T) { | ||
| 79 | 79 | { |
| 80 | 80 | name: "team past due after grace needs billing action", |
| 81 | 81 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 82 | - if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | |
| 83 | - OrgID: orgID, | |
| 84 | - Plan: billing.PlanTeam, | |
| 85 | - Status: billing.SubscriptionStatusActive, | |
| 86 | - StripeSubscriptionID: "sub_lapsed", | |
| 87 | - StripeSubscriptionItemID: "si_lapsed", | |
| 88 | - CurrentPeriodStart: now, | |
| 89 | - CurrentPeriodEnd: now.Add(30 * 24 * time.Hour), | |
| 90 | - LastWebhookEventID: "evt_active", | |
| 91 | - }); err != nil { | |
| 82 | + if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "lapsed"); err != nil { | |
| 92 | 83 | return err |
| 93 | 84 | } |
| 94 | 85 | _, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due") |
@@ -98,6 +89,22 @@ func TestCheckOrgFeature(t *testing.T) { | ||
| 98 | 89 | want: false, |
| 99 | 90 | reason: entitlements.ReasonBillingActionNeeded, |
| 100 | 91 | }, |
| 92 | + { | |
| 93 | + name: "team locked without grace needs billing action", | |
| 94 | + mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | |
| 95 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusPastDue, "locked") | |
| 96 | + }, | |
| 97 | + want: false, | |
| 98 | + reason: entitlements.ReasonBillingActionNeeded, | |
| 99 | + }, | |
| 100 | + { | |
| 101 | + name: "enterprise stub does not unlock team features", | |
| 102 | + mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | |
| 103 | + return setSubscription(ctx, deps, orgID, now, billing.PlanEnterprise, billing.SubscriptionStatusActive, "enterprise") | |
| 104 | + }, | |
| 105 | + want: false, | |
| 106 | + reason: entitlements.ReasonEnterpriseContactSales, | |
| 107 | + }, | |
| 101 | 108 | } |
| 102 | 109 | |
| 103 | 110 | for _, tt := range tests { |
@@ -130,6 +137,103 @@ func TestCheckOrgFeature(t *testing.T) { | ||
| 130 | 137 | } |
| 131 | 138 | } |
| 132 | 139 | |
| 140 | +func TestForOrgCanUseAndLimit(t *testing.T) { | |
| 141 | + t.Parallel() | |
| 142 | + ctx := context.Background() | |
| 143 | + pool, orgID := setupEntitlementOrg(t) | |
| 144 | + now := time.Now().UTC().Truncate(time.Second) | |
| 145 | + if err := setSubscription(ctx, billing.Deps{Pool: pool}, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "limits"); err != nil { | |
| 146 | + t.Fatalf("set subscription: %v", err) | |
| 147 | + } | |
| 148 | + | |
| 149 | + set, err := entitlements.ForOrg(ctx, entitlements.Deps{ | |
| 150 | + Pool: pool, | |
| 151 | + Now: func() time.Time { return now }, | |
| 152 | + }, orgID) | |
| 153 | + if err != nil { | |
| 154 | + t.Fatalf("ForOrg: %v", err) | |
| 155 | + } | |
| 156 | + for _, feature := range []entitlements.Feature{ | |
| 157 | + entitlements.FeatureOrgSecretTeams, | |
| 158 | + entitlements.FeatureOrgAdvancedBranchProtection, | |
| 159 | + entitlements.FeatureOrgRequiredReviewers, | |
| 160 | + entitlements.FeatureOrgActionsSecrets, | |
| 161 | + entitlements.FeatureOrgActionsVariables, | |
| 162 | + entitlements.FeatureOrgPrivateCollaboration, | |
| 163 | + entitlements.FeatureOrgStorageQuota, | |
| 164 | + entitlements.FeatureOrgActionsMinutesQuota, | |
| 165 | + } { | |
| 166 | + if decision := set.CanUse(feature); !decision.Allowed { | |
| 167 | + t.Fatalf("feature %s decision=%+v, want allowed", feature, decision) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + collab, err := set.Limit(entitlements.LimitOrgPrivateCollaboration) | |
| 171 | + if err != nil { | |
| 172 | + t.Fatalf("Limit private collaboration: %v", err) | |
| 173 | + } | |
| 174 | + if !collab.Allowed || !collab.Defined || !collab.Unlimited || collab.Unit != "collaborators" { | |
| 175 | + t.Fatalf("private collaboration limit = %+v", collab) | |
| 176 | + } | |
| 177 | + storage, err := set.Limit(entitlements.LimitOrgStorageQuota) | |
| 178 | + if err != nil { | |
| 179 | + t.Fatalf("Limit storage: %v", err) | |
| 180 | + } | |
| 181 | + if !storage.Allowed || storage.Defined || storage.Unit != "bytes" { | |
| 182 | + t.Fatalf("storage limit = %+v, want allowed but deferred concrete quota", storage) | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +func TestUnknownFeatureAndLimit(t *testing.T) { | |
| 187 | + t.Parallel() | |
| 188 | + ctx := context.Background() | |
| 189 | + pool, orgID := setupEntitlementOrg(t) | |
| 190 | + _, err := entitlements.CheckOrgFeature(ctx, entitlements.Deps{Pool: pool}, orgID, entitlements.Feature("org.mystery")) | |
| 191 | + if !errors.Is(err, entitlements.ErrUnknownFeature) { | |
| 192 | + t.Fatalf("CheckOrgFeature unknown err=%v, want ErrUnknownFeature", err) | |
| 193 | + } | |
| 194 | + set, err := entitlements.ForOrg(ctx, entitlements.Deps{Pool: pool}, orgID) | |
| 195 | + if err != nil { | |
| 196 | + t.Fatalf("ForOrg: %v", err) | |
| 197 | + } | |
| 198 | + _, err = set.Limit(entitlements.Limit("org.mystery_limit")) | |
| 199 | + if !errors.Is(err, entitlements.ErrUnknownLimit) { | |
| 200 | + t.Fatalf("Limit unknown err=%v, want ErrUnknownLimit", err) | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +func TestDecisionUpgradeBanner(t *testing.T) { | |
| 205 | + t.Parallel() | |
| 206 | + decision := entitlements.Decision{ | |
| 207 | + Feature: entitlements.FeatureOrgSecretTeams, | |
| 208 | + RequiredPlan: billing.PlanTeam, | |
| 209 | + Reason: entitlements.ReasonUpgradeRequired, | |
| 210 | + } | |
| 211 | + banner := decision.UpgradeBanner("Secret teams", "acme inc") | |
| 212 | + if banner.StatusCode != http.StatusPaymentRequired { | |
| 213 | + t.Fatalf("status=%d, want 402", banner.StatusCode) | |
| 214 | + } | |
| 215 | + if banner.ActionHref != "/organizations/acme%20inc/settings/billing" { | |
| 216 | + t.Fatalf("href=%q", banner.ActionHref) | |
| 217 | + } | |
| 218 | + if !strings.Contains(banner.Message, "require Team billing") { | |
| 219 | + t.Fatalf("message=%q", banner.Message) | |
| 220 | + } | |
| 221 | +} | |
| 222 | + | |
| 223 | +func setSubscription(ctx context.Context, deps billing.Deps, orgID int64, now time.Time, plan billing.Plan, status billing.SubscriptionStatus, suffix string) error { | |
| 224 | + _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | |
| 225 | + OrgID: orgID, | |
| 226 | + Plan: plan, | |
| 227 | + Status: status, | |
| 228 | + StripeSubscriptionID: "sub_" + suffix, | |
| 229 | + StripeSubscriptionItemID: "si_" + suffix, | |
| 230 | + CurrentPeriodStart: now, | |
| 231 | + CurrentPeriodEnd: now.Add(30 * 24 * time.Hour), | |
| 232 | + LastWebhookEventID: "evt_" + suffix, | |
| 233 | + }) | |
| 234 | + return err | |
| 235 | +} | |
| 236 | + | |
| 133 | 237 | func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) { |
| 134 | 238 | t.Helper() |
| 135 | 239 | pool := dbtest.NewTestDB(t) |