//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) } }) }