mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
Entitlements were keyed on the Stripe subscription alone, so a subscriber
who switched to a self-hosted hold kept paying for features the appview
cannot deliver, and could still reach checkout.
- billing.ActiveHoldChecker and Manager.onManagedHold gate every
entitlement. An empty default hold counts as managed: the user has no
explicit preference and falls back to the operator's primary managed
hold.
- The checker reads the primary DB, not the read replica. A hold switch
writes default_hold_did to the primary, and replica lag would keep
paid features alive after a switch away.
- db.GetUserDefaultHoldDID is the clean default-hold signal, unlike
GetUserHoldDID which falls back to a manifest hold_endpoint (a URL,
not a DID).
- Jetstream fails closed: an unresolvable hold reference is cached raw
rather than left empty, since an empty value reads as managed.
- UI: the billing tab is hidden on self-hosted, a cancel/manage banner
appears when a self-hosted user still has an active plan, the image
advisor returns managed_hold_required instead of upgrade_required,
and the checkout route returns 403. The portal stays open so existing
subscribers can still cancel.
Two consistency fixes fall out of wiring this up:
The settings UI reads the resolved default_hold_did rather than the raw
profile.DefaultHold. The profile field is the record value as written and
may be a URL-form reference; jetstream resolves it to a DID on the way
into the DB, and the server-side gate reads that resolved value. Comparing
the raw form against managed DIDs would show the "you are self-hosted"
banner and hide billing from a user whose entitlements say otherwise.
HasAIAdvisor falls back to the free tier's AIAdvisor setting when
off-managed instead of a hard false, matching GetWebhookLimits. Losing a
managed hold should drop a user to free-tier entitlements, not below them.
BEHAVIOR CHANGE for existing paying users on self-hosted holds: they lose
the AI advisor, supporter badge and paid webhook limits as soon as this
deploys, while Stripe keeps charging them. The only notice is the banner
on /settings/storage, which they have to visit to see. Decide on a
migration (notification, or a one-time reconciliation over active
subscriptions) before shipping this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
//go:build billing
|
|
|
|
package billing
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func testManager(t *testing.T) *Manager {
|
|
t.Helper()
|
|
cfg := &Config{
|
|
StripeSecretKey: "sk_test_dummy",
|
|
WebhookSecret: "whsec_dummy",
|
|
Tiers: []BillingTierConfig{
|
|
{Name: "free", MaxWebhooks: 1, WebhookAllTriggers: false, AIAdvisor: false, SupporterBadge: false},
|
|
{Name: "bosun", MaxWebhooks: 5, WebhookAllTriggers: true, AIAdvisor: true, SupporterBadge: true},
|
|
},
|
|
}
|
|
// nil privateKey/db are fine: the gated paths under test never touch them.
|
|
return New(cfg, nil, "did:web:appview", []string{"did:web:hold"}, "https://appview", nil)
|
|
}
|
|
|
|
// Off-managed users (not captains) get free-tier entitlements, and these paths
|
|
// short-circuit before any Stripe call.
|
|
func TestEntitlements_OffManagedHold_GetsFreeTier(t *testing.T) {
|
|
m := testManager(t)
|
|
m.SetActiveHoldChecker(func(string) bool { return false }) // self-hosted
|
|
|
|
if m.HasAIAdvisor("did:plc:user") {
|
|
t.Error("HasAIAdvisor should be false off a managed hold")
|
|
}
|
|
if max, all := m.GetWebhookLimits("did:plc:user"); max != 1 || all {
|
|
t.Errorf("GetWebhookLimits = (%d,%v), want (1,false)", max, all)
|
|
}
|
|
if badge := m.GetSupporterBadge("did:plc:user"); badge != "" {
|
|
t.Errorf("GetSupporterBadge = %q, want \"\"", badge)
|
|
}
|
|
}
|
|
|
|
// Captains bypass the gate entirely (this short-circuits before onManagedHold).
|
|
func TestEntitlements_Captain_BypassesGate(t *testing.T) {
|
|
m := testManager(t)
|
|
m.SetCaptainChecker(func(string) bool { return true })
|
|
m.SetActiveHoldChecker(func(string) bool { return false }) // even off-managed
|
|
|
|
if !m.HasAIAdvisor("did:plc:cap") {
|
|
t.Error("captain should have AI advisor")
|
|
}
|
|
if max, all := m.GetWebhookLimits("did:plc:cap"); max != -1 || !all {
|
|
t.Errorf("captain GetWebhookLimits = (%d,%v), want (-1,true)", max, all)
|
|
}
|
|
if badge := m.GetSupporterBadge("did:plc:cap"); badge != "Captain" {
|
|
t.Errorf("captain GetSupporterBadge = %q, want \"Captain\"", badge)
|
|
}
|
|
}
|
|
|
|
func TestWebhookConfigured(t *testing.T) {
|
|
m := testManager(t)
|
|
if !m.WebhookConfigured() {
|
|
t.Error("expected WebhookConfigured true when secret set")
|
|
}
|
|
|
|
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
|
|
m2 := New(cfg, nil, "did:web:appview", nil, "https://appview", nil)
|
|
if m2.WebhookConfigured() {
|
|
t.Error("expected WebhookConfigured false when secret empty")
|
|
}
|
|
}
|
|
|
|
// Empty webhook secret fails closed with a processing error (not a signature
|
|
// error), so the HTTP layer returns 5xx rather than silently accepting.
|
|
func TestHandleWebhook_EmptySecret_FailsClosed(t *testing.T) {
|
|
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
|
|
m := New(cfg, nil, "did:web:appview", nil, "https://appview", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
|
|
err := m.HandleWebhook(req)
|
|
if !errors.Is(err, ErrWebhookProcessing) {
|
|
t.Fatalf("expected ErrWebhookProcessing, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A bad signature is a client error (400, no Stripe retry).
|
|
func TestHandleWebhook_BadSignature(t *testing.T) {
|
|
m := testManager(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
|
|
req.Header.Set("Stripe-Signature", "t=123,v1=deadbeef")
|
|
err := m.HandleWebhook(req)
|
|
if !errors.Is(err, ErrWebhookSignature) {
|
|
t.Fatalf("expected ErrWebhookSignature, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The HTTP handler maps signature errors to 400 and processing errors to 500.
|
|
func TestHandleStripeWebhook_StatusCodes(t *testing.T) {
|
|
t.Run("bad signature -> 400", func(t *testing.T) {
|
|
m := testManager(t)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
|
|
req.Header.Set("Stripe-Signature", "t=123,v1=deadbeef")
|
|
rr := httptest.NewRecorder()
|
|
m.handleStripeWebhook(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rr.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("processing error -> 500", func(t *testing.T) {
|
|
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
|
|
m := New(cfg, nil, "did:web:appview", nil, "https://appview", nil) // empty secret
|
|
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
|
|
rr := httptest.NewRecorder()
|
|
m.handleStripeWebhook(rr, req)
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want 500", rr.Code)
|
|
}
|
|
})
|
|
}
|