diff --git a/tests/migration/v5_commercial_migration_test.go b/tests/migration/v5_commercial_migration_test.go index 532d6cde75..9ff6d853bb 100644 --- a/tests/migration/v5_commercial_migration_test.go +++ b/tests/migration/v5_commercial_migration_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -194,3 +195,148 @@ func TestV5PaidLicenseUpgrade_CommercialMigrationFailureMatrix(t *testing.T) { }) } } + +func TestV5PaidLicenseUpgrade_CommercialMigrationClearsAfterLocalRetry(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + + dataDir, _, _, _, _ := buildV5DataDir(t) + legacyLicense, err := pkglicensing.GenerateLicenseForTesting( + "legacy-retry@example.com", + pkglicensing.TierPro, + 365*24*time.Hour, + ) + require.NoError(t, err) + + persistence, err := pkglicensing.NewPersistence(dataDir) + require.NoError(t, err) + require.NoError(t, persistence.Save(legacyLicense)) + + grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ + LicenseID: "lic_v5_retry_migrated", + Tier: string(pkglicensing.TierPro), + PlanKey: "v5_pro_monthly_grandfathered", + State: "active", + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + Email: "legacy-retry@example.com", + }) + require.NoError(t, err) + pkglicensing.SetPublicKey(grantPublicKey) + t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + + var exchangeAvailable atomic.Bool + exchangeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/licenses/exchange", r.URL.Path) + + var req pkglicensing.ExchangeLegacyLicenseRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, legacyLicense, req.LegacyLicenseKey) + + if !exchangeAvailable.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "code": "service_unavailable", + "message": "exchange unavailable", + "retryable": true, + })) + return + } + + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(pkglicensing.ActivateInstallationResponse{ + License: pkglicensing.ActivateResponseLicense{ + LicenseID: "lic_v5_retry_migrated", + State: "active", + Tier: string(pkglicensing.TierPro), + Features: append([]string(nil), pkglicensing.TierFeatures[pkglicensing.TierPro]...), + }, + Installation: pkglicensing.ActivateResponseInstallation{ + InstallationID: "inst_v5_retry_migrated", + InstallationToken: "pit_live_v5_retry_migrated", + Status: "active", + }, + Grant: pkglicensing.GrantEnvelope{ + JWT: grantJWT, + JTI: "grant_v5_retry_migrated", + ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + })) + })) + defer exchangeServer.Close() + t.Setenv("PULSE_LICENSE_SERVER_URL", exchangeServer.URL) + + ctx := context.WithValue(context.Background(), api.OrgIDContextKey, "default") + mtp := config.NewMultiTenantPersistence(dataDir) + handlers := api.NewLicenseHandlers(mtp, false) + + svc := handlers.Service(ctx) + require.NotNil(t, svc) + assert.False(t, svc.IsActivated(), "unavailable exchange must leave v6 activation unset") + + store := config.NewFileBillingStore(dataDir) + state, err := store.GetBillingState("default") + require.NoError(t, err) + require.NotNil(t, state) + require.NotNil(t, state.CommercialMigration, "failed exchange must persist retryable migration state") + assert.Equal(t, pkglicensing.CommercialMigrationStatePending, state.CommercialMigration.State) + assert.Equal(t, pkglicensing.CommercialMigrationReasonExchangeUnavailable, state.CommercialMigration.Reason) + assert.Equal(t, pkglicensing.CommercialMigrationActionRetryActivation, state.CommercialMigration.RecommendedAction) + + entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) + entRec := httptest.NewRecorder() + handlers.HandleEntitlements(entRec, entReq) + require.Equal(t, http.StatusOK, entRec.Code) + + var pendingEntitlements api.EntitlementPayload + require.NoError(t, json.Unmarshal(entRec.Body.Bytes(), &pendingEntitlements)) + require.NotNil(t, pendingEntitlements.CommercialMigration) + assert.Equal(t, pkglicensing.CommercialMigrationStatePending, pendingEntitlements.CommercialMigration.State) + assert.False(t, pendingEntitlements.TrialEligible) + assert.Empty(t, pendingEntitlements.TrialEligibilityReason) + + handlers.StopAllBackgroundLoops() + exchangeAvailable.Store(true) + + handlers = api.NewLicenseHandlers(config.NewMultiTenantPersistence(dataDir), false) + t.Cleanup(handlers.StopAllBackgroundLoops) + + svc = handlers.Service(ctx) + require.NotNil(t, svc) + require.True(t, svc.IsActivated(), "local retry after exchange recovery must activate v6 entitlements") + + current := svc.Current() + require.NotNil(t, current) + assert.Equal(t, "lic_v5_retry_migrated", current.Claims.LicenseID) + assert.Equal(t, pkglicensing.TierPro, current.Claims.Tier) + assert.Equal(t, "v5_pro_monthly_grandfathered", current.Claims.PlanVersion) + + activationState, err := persistence.LoadActivationState() + require.NoError(t, err) + require.NotNil(t, activationState, "successful retry must persist v6 activation state") + assert.True(t, activationState.Continuity.LegacyMigration, "activation state must retain v5 migration continuity") + + legacyLeft, err := persistence.Load() + require.NoError(t, err) + assert.Equal(t, legacyLicense, legacyLeft, "legacy v5 license must remain persisted for downgrade") + + state, err = store.GetBillingState("default") + require.NoError(t, err) + require.NotNil(t, state) + assert.Nil(t, state.CommercialMigration, "successful local retry must clear unresolved migration state") + + entReq = httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) + entRec = httptest.NewRecorder() + handlers.HandleEntitlements(entRec, entReq) + require.Equal(t, http.StatusOK, entRec.Code) + + var activeEntitlements api.EntitlementPayload + require.NoError(t, json.Unmarshal(entRec.Body.Bytes(), &activeEntitlements)) + assert.Nil(t, activeEntitlements.CommercialMigration) + assert.Equal(t, "active", activeEntitlements.SubscriptionState) + assert.Equal(t, string(pkglicensing.TierPro), activeEntitlements.Tier) + assert.Equal(t, "v5_pro_monthly_grandfathered", activeEntitlements.PlanVersion) + assert.False(t, activeEntitlements.TrialEligible) + assert.Empty(t, activeEntitlements.TrialEligibilityReason) + assertEntitlementLimitAbsent(t, activeEntitlements.Limits, pkglicensing.MaxMonitoredSystemsLicenseGateKey) +}