mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 18:55:40 +00:00
81b31e4d3b
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.
418 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|