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 | store and scan the plan value, but product behavior should ask the | 189 | store and scan the plan value, but product behavior should ask the |
| 190 | entitlement package whether a feature key is available. | 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 | Expected feature keys: | 199 | Expected feature keys: |
| 193 | 200 | ||
| 194 | - `org.secret_teams` | 201 | - `org.secret_teams` |
@@ -205,6 +212,23 @@ the policy permission and the paid entitlement for gated writes. Denials | |||
| 205 | must preserve existing `policy.Maybe404` behavior where existence leaks | 212 | must preserve existing `policy.Maybe404` behavior where existence leaks |
| 206 | matter. | 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 | ## Downgrade behavior | 232 | ## Downgrade behavior |
| 209 | 233 | ||
| 210 | Downgrades must preserve customer data. Moving from Team to Free should | 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 | packaging, and makes downgrades/grace periods possible without | 131 | packaging, and makes downgrades/grace periods possible without |
| 132 | rewriting role checks. | 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 | ## Existence-leak guard | 141 | ## Existence-leak guard |
| 135 | 142 | ||
| 136 | `policy.Maybe404(decision, repo, actor)` maps a denial to a status | 143 | `policy.Maybe404(decision, repo, actor)` maps a denial to a status |
internal/entitlements/entitlements.gomodified@@ -5,6 +5,8 @@ package entitlements | |||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | "errors" | 7 | "errors" |
| 8 | + "net/http" | ||
| 9 | + "net/url" | ||
| 8 | "time" | 10 | "time" |
| 9 | 11 | ||
| 10 | "github.com/jackc/pgx/v5/pgxpool" | 12 | "github.com/jackc/pgx/v5/pgxpool" |
@@ -25,12 +27,22 @@ const ( | |||
| 25 | FeatureOrgActionsMinutesQuota Feature = "org.actions_minutes_quota" | 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 | type Reason string | 38 | type Reason string |
| 29 | 39 | ||
| 30 | const ( | 40 | const ( |
| 31 | - ReasonNone Reason = "" | 41 | + ReasonNone Reason = "" |
| 32 | - ReasonUpgradeRequired Reason = "upgrade_required" | 42 | + ReasonUpgradeRequired Reason = "upgrade_required" |
| 33 | - ReasonBillingActionNeeded Reason = "billing_action_needed" | 43 | + ReasonBillingActionNeeded Reason = "billing_action_needed" |
| 44 | + ReasonEnterpriseContactSales Reason = "enterprise_contact_sales" | ||
| 45 | + ReasonUnknownFeature Reason = "unknown_feature" | ||
| 34 | ) | 46 | ) |
| 35 | 47 | ||
| 36 | type Deps struct { | 48 | type Deps struct { |
@@ -45,31 +57,150 @@ type Decision struct { | |||
| 45 | Reason Reason | 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 | var ( | 89 | var ( |
| 49 | ErrPoolRequired = errors.New("entitlements: pool is required") | 90 | ErrPoolRequired = errors.New("entitlements: pool is required") |
| 50 | ErrOrgIDRequired = errors.New("entitlements: org id is required") | 91 | ErrOrgIDRequired = errors.New("entitlements: org id is required") |
| 51 | ErrUnknownFeature = errors.New("entitlements: unknown feature") | 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) { | 96 | +func New(deps Deps) Loader { |
| 55 | - if deps.Pool == nil { | 97 | + return Loader{deps: deps} |
| 56 | - return Decision{}, ErrPoolRequired | 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 | if orgID == 0 { | 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 | return Decision{}, ErrUnknownFeature | 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 | if err != nil { | 127 | if err != nil { |
| 66 | return Decision{}, err | 128 | return Decision{}, err |
| 67 | } | 129 | } |
| 68 | - now := time.Now().UTC() | 130 | + return set.CanUse(feature), nil |
| 69 | - if deps.Now != nil { | 131 | +} |
| 70 | - now = deps.Now().UTC() | 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 | func requiredPlanForFeature(feature Feature) billing.Plan { | 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 | func decideFeature(now time.Time, state billing.State, feature Feature) Decision { | 235 | func decideFeature(now time.Time, state billing.State, feature Feature) Decision { |
| 92 | decision := Decision{ | 236 | decision := Decision{ |
| 93 | Feature: feature, | 237 | Feature: feature, |
| 94 | RequiredPlan: requiredPlanForFeature(feature), | 238 | RequiredPlan: requiredPlanForFeature(feature), |
| 95 | Reason: ReasonUpgradeRequired, | 239 | Reason: ReasonUpgradeRequired, |
| 96 | } | 240 | } |
| 241 | + if decision.RequiredPlan == "" { | ||
| 242 | + decision.Reason = ReasonUnknownFeature | ||
| 243 | + return decision | ||
| 244 | + } | ||
| 97 | switch state.Plan { | 245 | switch state.Plan { |
| 98 | case billing.PlanEnterprise: | 246 | case billing.PlanEnterprise: |
| 99 | - decision.Allowed = true | 247 | + decision.Reason = ReasonEnterpriseContactSales |
| 100 | - decision.Reason = ReasonNone | ||
| 101 | return decision | 248 | return decision |
| 102 | case billing.PlanTeam: | 249 | case billing.PlanTeam: |
| 103 | switch state.SubscriptionStatus { | 250 | switch state.SubscriptionStatus { |
| 104 | case billing.SubscriptionStatusActive, | 251 | case billing.SubscriptionStatusActive, |
| 105 | - billing.SubscriptionStatusTrialing, | 252 | + billing.SubscriptionStatusTrialing: |
| 106 | - billing.SubscriptionStatusIncomplete: | ||
| 107 | decision.Allowed = true | 253 | decision.Allowed = true |
| 108 | decision.Reason = ReasonNone | 254 | decision.Reason = ReasonNone |
| 109 | return decision | 255 | return decision |
| 110 | case billing.SubscriptionStatusPastDue: | 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 | decision.Allowed = true | 258 | decision.Allowed = true |
| 113 | decision.Reason = ReasonNone | 259 | decision.Reason = ReasonNone |
| 114 | return decision | 260 | return decision |
internal/entitlements/entitlements_test.gomodified@@ -4,8 +4,11 @@ package entitlements_test | |||
| 4 | 4 | ||
| 5 | import ( | 5 | import ( |
| 6 | "context" | 6 | "context" |
| 7 | + "errors" | ||
| 7 | "io" | 8 | "io" |
| 8 | "log/slog" | 9 | "log/slog" |
| 10 | + "net/http" | ||
| 11 | + "strings" | ||
| 9 | "testing" | 12 | "testing" |
| 10 | "time" | 13 | "time" |
| 11 | 14 | ||
@@ -39,34 +42,31 @@ func TestCheckOrgFeature(t *testing.T) { | |||
| 39 | { | 42 | { |
| 40 | name: "team active allows feature", | 43 | name: "team active allows feature", |
| 41 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | 44 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 42 | - _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | 45 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "active") |
| 43 | - OrgID: orgID, | 46 | + }, |
| 44 | - Plan: billing.PlanTeam, | 47 | + want: true, |
| 45 | - Status: billing.SubscriptionStatusActive, | 48 | + reason: entitlements.ReasonNone, |
| 46 | - StripeSubscriptionID: "sub_active", | 49 | + }, |
| 47 | - StripeSubscriptionItemID: "si_active", | 50 | + { |
| 48 | - CurrentPeriodStart: now, | 51 | + name: "team trialing allows feature", |
| 49 | - CurrentPeriodEnd: now.Add(30 * 24 * time.Hour), | 52 | + mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 50 | - LastWebhookEventID: "evt_active", | 53 | + return setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusTrialing, "trialing") |
| 51 | - }) | ||
| 52 | - return err | ||
| 53 | }, | 54 | }, |
| 54 | want: true, | 55 | want: true, |
| 55 | reason: entitlements.ReasonNone, | 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 | name: "team past due within grace still allows feature", | 67 | name: "team past due within grace still allows feature", |
| 59 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | 68 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 60 | - if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | 69 | + if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "grace"); err != nil { |
| 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 { | ||
| 70 | return err | 70 | return err |
| 71 | } | 71 | } |
| 72 | _, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due") | 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 | name: "team past due after grace needs billing action", | 80 | name: "team past due after grace needs billing action", |
| 81 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { | 81 | mutate: func(ctx context.Context, deps billing.Deps, orgID int64, now time.Time) error { |
| 82 | - if _, err := billing.ApplySubscriptionSnapshot(ctx, deps, billing.SubscriptionSnapshot{ | 82 | + if err := setSubscription(ctx, deps, orgID, now, billing.PlanTeam, billing.SubscriptionStatusActive, "lapsed"); err != nil { |
| 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 { | ||
| 92 | return err | 83 | return err |
| 93 | } | 84 | } |
| 94 | _, err := billing.MarkPastDue(ctx, deps, orgID, now.Add(24*time.Hour), "evt_past_due") | 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 | want: false, | 89 | want: false, |
| 99 | reason: entitlements.ReasonBillingActionNeeded, | 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 | for _, tt := range tests { | 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 | func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) { | 237 | func setupEntitlementOrg(t *testing.T) (*pgxpool.Pool, int64) { |
| 134 | t.Helper() | 238 | t.Helper() |
| 135 | pool := dbtest.NewTestDB(t) | 239 | pool := dbtest.NewTestDB(t) |