Files
pulse/internal/api/stripe_webhook_handlers_additional_test.go
T
rcourtman 81b31e4d3b Remove monitored-system volume caps
Retire runtime/API/UI monitored-system volume enforcement now that infrastructure monitoring is no longer capped.

Keep only legacy metadata scrubbing and purchase-start compatibility for old max_monitored_systems references.

Rename the remaining preview surface to monitored-system impact and make previews explanatory rather than save-blocking.

Update subsystem contracts and RA7 evidence for the caps-retired invariant.
2026-05-05 12:59:59 +01:00

418 lines
13 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/license"
"github.com/rcourtman/pulse-go-rewrite/internal/license/entitlements"
"github.com/stripe/stripe-go/v82"
"github.com/stripe/stripe-go/v82/webhook"
)
func TestResolvePulseDataDir_UsesCanonicalRuntimeDataDir(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
explicit := t.TempDir()
if got := resolvePulseDataDir(explicit); got != explicit {
t.Fatalf("resolvePulseDataDir(explicit) = %q, want %q", got, explicit)
}
t.Setenv("PULSE_DATA_DIR", explicit)
if got := resolvePulseDataDir(""); got != explicit {
t.Fatalf("resolvePulseDataDir(\"\") = %q, want %q", got, explicit)
}
}
func TestStripeWebhook_SubscriptionUpdated_BackfillsIndexAndAppliesState(t *testing.T) {
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_sub_updated")
tmp := t.TempDir()
persistence := config.NewMultiTenantPersistence(tmp)
rbacProvider := NewTenantRBACProvider(tmp)
billingStore := config.NewFileBillingStore(tmp)
h := NewStripeWebhookHandlers(billingStore, persistence, rbacProvider, nil, nil, true, tmp)
orgID := "org_sub_updated"
createTestOrg(t, persistence, orgID, "owner@example.com")
err := billingStore.SaveBillingState(orgID, &entitlements.BillingState{
Capabilities: []string{},
Limits: map[string]int64{},
MetersEnabled: []string{},
PlanVersion: string(entitlements.SubStateTrial),
SubscriptionState: entitlements.SubStateTrial,
StripeCustomerID: "cus_scan_only",
StripeSubscriptionID: "sub_initial",
})
if err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
event := map[string]any{
"id": "evt_sub_updated_backfill",
"type": "customer.subscription.updated",
"data": map[string]any{
"object": map[string]any{
"id": "sub_new",
"customer": "cus_scan_only",
"status": "past_due",
"items": map[string]any{
"data": []map[string]any{
{"price": map[string]any{"id": " "}},
{"price": map[string]any{"id": " price_gold "}},
},
},
},
},
}
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal event: %v", err)
}
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: "whsec_test_sub_updated",
Timestamp: time.Now(),
Scheme: "v1",
})
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe", bytes.NewReader(payload))
req.Header.Set("Stripe-Signature", signed.Header)
rr := httptest.NewRecorder()
h.HandleStripeWebhook(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusOK)
}
state, err := billingStore.GetBillingState(orgID)
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if state == nil {
t.Fatalf("expected billing state")
}
if state.SubscriptionState != entitlements.SubStateGrace {
t.Fatalf("subscription_state=%q, want %q", state.SubscriptionState, entitlements.SubStateGrace)
}
if state.StripePriceID != "price_gold" {
t.Fatalf("stripe_price_id=%q, want %q", state.StripePriceID, "price_gold")
}
if state.PlanVersion != "stripe_price:price_gold" {
t.Fatalf("plan_version=%q, want %q", state.PlanVersion, "stripe_price:price_gold")
}
if _, ok := state.Limits["max_monitored_systems"]; ok {
t.Fatalf("expected retired max_monitored_systems limit to be omitted, got %+v", state.Limits)
}
if state.StripeSubscriptionID != "sub_new" {
t.Fatalf("stripe_subscription_id=%q, want %q", state.StripeSubscriptionID, "sub_new")
}
if len(state.Capabilities) == 0 {
t.Fatalf("expected paid capabilities to be granted")
}
hasAutoFix := false
for _, capability := range state.Capabilities {
if capability == license.FeatureAIAutoFix {
hasAutoFix = true
break
}
}
if !hasAutoFix {
t.Fatalf("expected capabilities to include %q, got %v", license.FeatureAIAutoFix, state.Capabilities)
}
mappedOrgID, ok, err := h.index.LookupOrgID("cus_scan_only")
if err != nil {
t.Fatalf("LookupOrgID: %v", err)
}
if !ok || mappedOrgID != orgID {
t.Fatalf("index mapping mismatch: org=%q ok=%v, want org=%q ok=true", mappedOrgID, ok, orgID)
}
}
func TestStripeWebhook_SubscriptionUpdated_RecognizesGrandfatheredRecurringPriceWithoutMetadata(t *testing.T) {
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_grandfathered_price")
tmp := t.TempDir()
persistence := config.NewMultiTenantPersistence(tmp)
rbacProvider := NewTenantRBACProvider(tmp)
billingStore := config.NewFileBillingStore(tmp)
h := NewStripeWebhookHandlers(billingStore, persistence, rbacProvider, nil, nil, true, tmp)
orgID := "org_grandfathered_price"
createTestOrg(t, persistence, orgID, "owner@example.com")
err := billingStore.SaveBillingState(orgID, &entitlements.BillingState{
Capabilities: []string{},
Limits: map[string]int64{},
MetersEnabled: []string{},
PlanVersion: string(entitlements.SubStateTrial),
SubscriptionState: entitlements.SubStateTrial,
StripeCustomerID: "cus_grandfathered_only",
StripeSubscriptionID: "sub_initial",
})
if err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
event := map[string]any{
"id": "evt_sub_updated_grandfathered",
"type": "customer.subscription.updated",
"data": map[string]any{
"object": map[string]any{
"id": "sub_grandfathered",
"customer": "cus_grandfathered_only",
"status": "active",
"items": map[string]any{
"data": []map[string]any{
{"price": map[string]any{"id": "price_1SgDxvBrHBocJIGHStaGuiAX"}},
},
},
},
},
}
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal event: %v", err)
}
signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{
Payload: payload,
Secret: "whsec_test_grandfathered_price",
Timestamp: time.Now(),
Scheme: "v1",
})
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/stripe", bytes.NewReader(payload))
req.Header.Set("Stripe-Signature", signed.Header)
rr := httptest.NewRecorder()
h.HandleStripeWebhook(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d, want %d", rr.Code, http.StatusOK)
}
state, err := billingStore.GetBillingState(orgID)
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if state == nil {
t.Fatal("expected billing state")
}
if state.PlanVersion != "v5_pro_monthly_grandfathered" {
t.Fatalf("plan_version=%q, want %q", state.PlanVersion, "v5_pro_monthly_grandfathered")
}
if state.StripePriceID != "price_1SgDxvBrHBocJIGHStaGuiAX" {
t.Fatalf("stripe_price_id=%q, want %q", state.StripePriceID, "price_1SgDxvBrHBocJIGHStaGuiAX")
}
if state.SubscriptionState != entitlements.SubStateActive {
t.Fatalf("subscription_state=%q, want %q", state.SubscriptionState, entitlements.SubStateActive)
}
if got, ok := state.Limits["max_monitored_systems"]; ok && got != 0 {
t.Fatalf("limits[max_monitored_systems]=%d, want uncapped/absent", got)
}
}
func TestStripeWebhook_SubscriptionStatusMappingAndPaidCapabilities(t *testing.T) {
t.Parallel()
tests := []struct {
name string
status string
want entitlements.SubscriptionState
wantPaid bool
}{
{name: "active", status: "active", want: entitlements.SubStateActive, wantPaid: true},
{name: "trialing", status: "trialing", want: entitlements.SubStateTrial, wantPaid: true},
{name: "past due", status: "past_due", want: entitlements.SubStateGrace, wantPaid: true},
{name: "unpaid", status: "unpaid", want: entitlements.SubStateGrace, wantPaid: true},
{name: "canceled", status: "canceled", want: entitlements.SubStateCanceled, wantPaid: false},
{name: "paused", status: "paused", want: entitlements.SubStateSuspended, wantPaid: false},
{name: "incomplete", status: "incomplete", want: entitlements.SubStateExpired, wantPaid: false},
{name: "incomplete expired", status: "incomplete_expired", want: entitlements.SubStateExpired, wantPaid: false},
{name: "unknown defaults expired", status: " UNKNOWN ", want: entitlements.SubStateExpired, wantPaid: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := mapStripeSubscriptionStatusToState(tt.status)
if got != tt.want {
t.Fatalf("state=%q, want %q", got, tt.want)
}
if paid := shouldGrantPaidCapabilities(got); paid != tt.wantPaid {
t.Fatalf("paid=%v, want %v", paid, tt.wantPaid)
}
})
}
}
func TestStripeWebhook_FirstPriceID(t *testing.T) {
t.Parallel()
var empty stripeSubscription
if got := firstPriceID(empty); got != "" {
t.Fatalf("empty firstPriceID=%q, want empty", got)
}
var sub stripeSubscription
err := json.Unmarshal([]byte(`{"items":{"data":[{"price":{"id":" "}},{"price":{"id":" price_123 "}}]}}`), &sub)
if err != nil {
t.Fatalf("unmarshal stripe subscription: %v", err)
}
if got := firstPriceID(sub); got != "price_123" {
t.Fatalf("firstPriceID=%q, want %q", got, "price_123")
}
}
func TestStripeWebhook_HandleEvent_ErrorsAndUnhandled(t *testing.T) {
t.Parallel()
h := &StripeWebhookHandlers{}
if err := h.handleEvent(context.Background(), nil, nil); err == nil {
t.Fatalf("expected nil event error")
}
invalidUpdated := &stripe.Event{
Type: "customer.subscription.updated",
Data: &stripe.EventData{Raw: json.RawMessage(`{`)},
}
if err := h.handleEvent(context.Background(), invalidUpdated, nil); err == nil {
t.Fatalf("expected decode error for subscription.updated")
}
unhandled := &stripe.Event{
ID: "evt_unhandled",
Type: "customer.created",
}
if err := h.handleEvent(context.Background(), unhandled, nil); err != nil {
t.Fatalf("unexpected error for unhandled event: %v", err)
}
}
func TestStripeWebhookDeduperDoReturnsDuplicateAfterSuccessfulHandle(t *testing.T) {
t.Parallel()
d := newStripeWebhookDeduper(t.TempDir())
d.pruneTTL = 0
calls := 0
already, err := d.Do("evt_first", func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("first Do error: %v", err)
}
if already {
t.Fatal("first Do should not be treated as duplicate")
}
if calls != 1 {
t.Fatalf("handler calls = %d, want 1", calls)
}
if _, err := os.Stat(d.donePath("evt_first")); err != nil {
t.Fatalf("expected done marker to be written: %v", err)
}
already, err = d.Do("evt_first", func() error {
calls++
return nil
})
if err != nil {
t.Fatalf("second Do error: %v", err)
}
if !already {
t.Fatal("second Do should be treated as duplicate")
}
if calls != 1 {
t.Fatalf("handler calls after duplicate = %d, want 1", calls)
}
}
func TestStripeWebhookDeduperDoReturnsInFlightWhenLockExists(t *testing.T) {
t.Parallel()
d := newStripeWebhookDeduper(t.TempDir())
if err := os.WriteFile(d.lockPath("evt_inflight"), []byte("lock"), 0o600); err != nil {
t.Fatalf("write lock file: %v", err)
}
calls := 0
already, err := d.Do("evt_inflight", func() error {
calls++
return nil
})
if already {
t.Fatal("in-flight event should not be treated as completed duplicate")
}
if !errors.Is(err, errStripeWebhookEventInFlight) {
t.Fatalf("Do error = %v, want %v", err, errStripeWebhookEventInFlight)
}
if calls != 0 {
t.Fatalf("handler calls = %d, want 0", calls)
}
}
func TestStripeWebhookDeduperDoPrunesExpiredArtifacts(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.April, 22, 12, 0, 0, 0, time.UTC)
d := newStripeWebhookDeduper(t.TempDir())
d.now = func() time.Time { return now }
d.doneTTL = 24 * time.Hour
d.lockTTL = 10 * time.Minute
d.pruneTTL = 0
writeArtifact := func(path string, modTime time.Time) {
t.Helper()
if err := os.WriteFile(path, []byte("artifact"), 0o600); err != nil {
t.Fatalf("write artifact %s: %v", path, err)
}
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatalf("chtimes %s: %v", path, err)
}
}
oldDone := d.donePath("evt_old_done")
recentDone := d.donePath("evt_recent_done")
oldLock := d.lockPath("evt_old_lock")
recentLock := d.lockPath("evt_recent_lock")
oldTmp := d.donePath("evt_old_tmp") + ".tmp"
writeArtifact(oldDone, now.Add(-d.doneTTL-time.Minute))
writeArtifact(recentDone, now.Add(-d.doneTTL+time.Minute))
writeArtifact(oldLock, now.Add(-d.lockTTL-time.Minute))
writeArtifact(recentLock, now.Add(-d.lockTTL+time.Minute))
writeArtifact(oldTmp, now.Add(-d.lockTTL-time.Minute))
already, err := d.Do("evt_new", func() error { return nil })
if err != nil {
t.Fatalf("Do error: %v", err)
}
if already {
t.Fatal("new event should not be treated as duplicate")
}
for _, path := range []string{oldDone, oldLock, oldTmp} {
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected %s to be pruned, stat err=%v", path, err)
}
}
for _, path := range []string{recentDone, recentLock, d.donePath("evt_new")} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to remain, stat err=%v", path, err)
}
}
}