package api import ( "context" "crypto/ed25519" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "sort" "testing" "time" "github.com/golang-jwt/jwt/v5" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/hosted" "github.com/rcourtman/pulse-go-rewrite/internal/license" "github.com/rcourtman/pulse-go-rewrite/internal/license/entitlements" "github.com/rcourtman/pulse-go-rewrite/internal/models" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) func issueHostedLifecycleTrialInitiationToken(t *testing.T, h *LicenseHandlers, orgID, returnURL string) string { t.Helper() if h == nil || h.trialInitiations == nil { t.Fatal("trial initiation store is not configured") } token, err := h.trialInitiations.issue(orgID, returnURL, time.Now().UTC().Add(trialSignupInitiationTTL)) if err != nil { t.Fatalf("issue trial initiation token: %v", err) } return token } func TestHostedLifecycle(t *testing.T) { t.Run("Signup_SeedsTrialBillingState_ForBillingAdmin", func(t *testing.T) { router, _, _, _, baseDir := newHostedSignupTestRouter(t, true) rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`) if rec.Code != http.StatusCreated { t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var signupResp struct { OrgID string `json:"org_id"` } if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil { t.Fatalf("decode signup response: %v", err) } if signupResp.OrgID == "" { t.Fatal("expected signup response org_id to be populated") } billingStore := config.NewFileBillingStore(baseDir) stored, err := billingStore.GetBillingState(signupResp.OrgID) if err != nil { t.Fatalf("GetBillingState(%s) failed: %v", signupResp.OrgID, err) } if stored == nil { t.Fatal("expected seeded billing state after hosted signup") } if stored.SubscriptionState != entitlements.SubStateTrial { t.Fatalf("subscription_state=%q, want %q", stored.SubscriptionState, entitlements.SubStateTrial) } if stored.PlanVersion != "cloud_trial" { t.Fatalf("plan_version=%q, want %q", stored.PlanVersion, "cloud_trial") } if stored.TrialStartedAt == nil { t.Fatal("expected trial_started_at to be populated") } if stored.TrialEndsAt == nil { t.Fatal("expected trial_ends_at to be populated") } if !stored.QuickstartCreditsGranted { t.Fatal("expected hosted signup billing state to grant quickstart credits") } if stored.QuickstartCreditsGrantedAt == nil { t.Fatal("expected hosted signup quickstart grant timestamp to be populated") } expectedCaps := append([]string(nil), cloudCapabilitiesFromLicensing()...) sort.Strings(expectedCaps) gotCaps := append([]string(nil), stored.Capabilities...) sort.Strings(gotCaps) if !reflect.DeepEqual(gotCaps, expectedCaps) { t.Fatalf("seeded capabilities mismatch\n got: %v\nwant: %v", gotCaps, expectedCaps) } billingHandlers := NewBillingStateHandlers(billingStore, true) req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/"+signupResp.OrgID+"/billing-state", nil) req.SetPathValue("id", signupResp.OrgID) rec2 := httptest.NewRecorder() billingHandlers.HandleGetBillingState(rec2, req) if rec2.Code != http.StatusOK { t.Fatalf("billing-admin status=%d, want %d: %s", rec2.Code, http.StatusOK, rec2.Body.String()) } var adminPayload entitlements.BillingState if err := json.NewDecoder(rec2.Body).Decode(&adminPayload); err != nil { t.Fatalf("decode billing-admin response: %v", err) } if adminPayload.SubscriptionState != entitlements.SubStateTrial { t.Fatalf("billing-admin subscription_state=%q, want %q", adminPayload.SubscriptionState, entitlements.SubStateTrial) } if adminPayload.PlanVersion != "cloud_trial" { t.Fatalf("billing-admin plan_version=%q, want %q", adminPayload.PlanVersion, "cloud_trial") } adminCaps := append([]string(nil), adminPayload.Capabilities...) sort.Strings(adminCaps) if !reflect.DeepEqual(adminCaps, expectedCaps) { t.Fatalf("billing-admin capabilities mismatch\n got: %v\nwant: %v", adminCaps, expectedCaps) } }) t.Run("Signup_Billing_Entitlements_Flow", func(t *testing.T) { router, mtp, _, _, baseDir := newHostedSignupTestRouter(t, true) rec := doHostedSignupRequest(router, `{"email":"owner@example.com","org_name":"My Organization"}`) if rec.Code != http.StatusCreated { t.Fatalf("signup status=%d, want %d: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var signupResp struct { OrgID string `json:"org_id"` } if err := json.NewDecoder(rec.Body).Decode(&signupResp); err != nil { t.Fatalf("decode signup response: %v", err) } if signupResp.OrgID == "" { t.Fatal("expected signup response org_id to be populated") } // Write Pro billing state via the billing store. billingStore := config.NewFileBillingStore(baseDir) if err := billingStore.SaveBillingState(signupResp.OrgID, &entitlements.BillingState{ Capabilities: append([]string(nil), license.TierFeatures[license.TierPro]...), Limits: map[string]int64{}, MetersEnabled: []string{}, PlanVersion: string(license.TierPro), SubscriptionState: entitlements.SubStateActive, }); err != nil { t.Fatalf("SaveBillingState(%s) failed: %v", signupResp.OrgID, err) } handlers := NewLicenseHandlers(mtp, true) ctx := context.WithValue(context.Background(), OrgIDContextKey, signupResp.OrgID) // GET entitlements and verify capabilities match billing state. entReq := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) entRec := httptest.NewRecorder() handlers.HandleEntitlements(entRec, entReq) if entRec.Code != http.StatusOK { t.Fatalf("entitlements status=%d, want %d: %s", entRec.Code, http.StatusOK, entRec.Body.String()) } var payload EntitlementPayload if err := json.NewDecoder(entRec.Body).Decode(&payload); err != nil { t.Fatalf("decode entitlements payload: %v", err) } if payload.SubscriptionState != string(license.SubStateActive) { t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, license.SubStateActive) } expectedCaps := append([]string(nil), license.TierFeatures[license.TierPro]...) sort.Strings(expectedCaps) gotCaps := append([]string(nil), payload.Capabilities...) sort.Strings(gotCaps) if !reflect.DeepEqual(gotCaps, expectedCaps) { t.Fatalf("capabilities mismatch\n got: %v\nwant: %v", gotCaps, expectedCaps) } // Verify HasFeature() returns true for billed (Pro) capabilities. svc, _, err := handlers.getTenantComponents(ctx) if err != nil { t.Fatalf("getTenantComponents: %v", err) } if !svc.HasFeature(license.FeatureAIAutoFix) { t.Fatalf("expected HasFeature(%q)=true for billed capability", license.FeatureAIAutoFix) } }) t.Run("Trial_Start_Countdown_Expiry", func(t *testing.T) { baseDir := t.TempDir() mtp := config.NewMultiTenantPersistence(baseDir) handlers := NewLicenseHandlers(mtp, false, &config.Config{PublicURL: "https://pulse.example.com"}) ctx := context.WithValue(context.Background(), OrgIDContextKey, "default") pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatalf("GenerateKey: %v", err) } handlers.trialRedeemer = func(token string) (*hostedTrialRedemptionResponse, error) { claims, err := pkglicensing.VerifyTrialActivationToken(token, pub, "", time.Now().UTC()) if err != nil { return nil, err } return issueTrialRedemptionResponse(t, priv, claims.OrgID, claims.InstanceHost, claims.Email, time.Now().UTC()), nil } t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub)) handlers.trialRedeemer = func(string) (*hostedTrialRedemptionResponse, error) { return issueTrialRedemptionResponse(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now()), nil } returnURL := "https://pulse.example.com/auth/trial-activate" instanceToken := issueHostedLifecycleTrialInitiationToken(t, handlers, "default", returnURL) token, err := pkglicensing.SignTrialActivationToken(priv, pkglicensing.TrialActivationClaims{ OrgID: "default", Email: "owner@example.com", InstanceHost: "pulse.example.com", InstanceToken: instanceToken, ReturnURL: returnURL, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), }, }) if err != nil { t.Fatalf("SignTrialActivationToken: %v", err) } startReq := httptest.NewRequest(http.MethodGet, "/auth/trial-activate?token="+url.QueryEscape(token), nil).WithContext(ctx) startReq.Host = "pulse.example.com" startRec := httptest.NewRecorder() handlers.HandleTrialActivation(startRec, startReq) if startRec.Code != http.StatusTemporaryRedirect { t.Fatalf("trial activation status=%d, want %d: %s", startRec.Code, http.StatusTemporaryRedirect, startRec.Body.String()) } if got := startRec.Header().Get("Location"); got != "/settings/system-pro?trial=activated" { t.Fatalf("trial activation redirect=%q, want %q", got, "/settings/system-pro?trial=activated") } billingStore := config.NewFileBillingStore(baseDir) state, err := billingStore.GetBillingState("default") if err != nil || state == nil { t.Fatalf("GetBillingState(default) failed: %v state=%v", err, state) } if state.SubscriptionState != entitlements.SubStateTrial { t.Fatalf("trial activation subscription_state=%q, want %q", state.SubscriptionState, entitlements.SubStateTrial) } rawData, err := os.ReadFile(filepath.Join(baseDir, "billing.json")) if err != nil { t.Fatalf("ReadFile(billing.json): %v", err) } var rawState entitlements.BillingState if err := json.Unmarshal(rawData, &rawState); err != nil { t.Fatalf("Unmarshal(raw billing.json): %v", err) } if rawState.SubscriptionState != "" { t.Fatalf("raw subscription_state=%q, want empty", rawState.SubscriptionState) } if rawState.PlanVersion != "" { t.Fatalf("raw plan_version=%q, want empty", rawState.PlanVersion) } if len(rawState.Capabilities) != 0 { t.Fatalf("raw capabilities=%v, want empty", rawState.Capabilities) } if rawState.TrialEndsAt != nil { t.Fatalf("raw trial_ends_at=%v, want nil", rawState.TrialEndsAt) } if rawState.TrialStartedAt == nil { t.Fatal("expected raw trial_started_at to be preserved") } if rawState.EntitlementJWT == "" { t.Fatal("expected entitlement_jwt ciphertext to be stored") } if rawState.EntitlementJWT == state.EntitlementJWT { t.Fatal("expected entitlement_jwt to be encrypted at rest") } if rawState.EntitlementRefreshToken == "" { t.Fatal("expected entitlement_refresh_token ciphertext to be stored") } if rawState.EntitlementRefreshToken == "etr_test_default" { t.Fatal("expected entitlement_refresh_token to be encrypted at rest") } entReq1 := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) entRec1 := httptest.NewRecorder() handlers.HandleEntitlements(entRec1, entReq1) if entRec1.Code != http.StatusOK { t.Fatalf("entitlements status=%d, want %d: %s", entRec1.Code, http.StatusOK, entRec1.Body.String()) } var payload1 EntitlementPayload if err := json.NewDecoder(entRec1.Body).Decode(&payload1); err != nil { t.Fatalf("decode entitlements payload: %v", err) } if payload1.SubscriptionState != string(license.SubStateTrial) { t.Fatalf("subscription_state=%q, want %q", payload1.SubscriptionState, license.SubStateTrial) } if payload1.TrialDaysRemaining == nil || *payload1.TrialDaysRemaining <= 0 { t.Fatalf("expected trial_days_remaining to be populated and > 0, got %v", payload1.TrialDaysRemaining) } // Manually expire the trial by replacing the entitlement lease with an already-expired one. loaded, err := billingStore.GetBillingState("default") if err != nil || loaded == nil { t.Fatalf("GetBillingState(default) failed: %v state=%v", err, loaded) } loaded.EntitlementJWT = issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now().Add(-15*24*time.Hour)) if err := billingStore.SaveBillingState("default", loaded); err != nil { t.Fatalf("SaveBillingState(default) failed: %v", err) } entReq2 := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx) entRec2 := httptest.NewRecorder() handlers.HandleEntitlements(entRec2, entReq2) if entRec2.Code != http.StatusOK { t.Fatalf("entitlements (post-expiry) status=%d, want %d: %s", entRec2.Code, http.StatusOK, entRec2.Body.String()) } var payload2 EntitlementPayload if err := json.NewDecoder(entRec2.Body).Decode(&payload2); err != nil { t.Fatalf("decode entitlements payload (post-expiry): %v", err) } if payload2.SubscriptionState != string(license.SubStateExpired) { t.Fatalf("subscription_state=%q, want %q", payload2.SubscriptionState, license.SubStateExpired) } if sliceContainsString(payload2.Capabilities, license.FeatureAIAutoFix) { t.Fatalf("expected pro-only capability %q to be removed on expiry; got %v", license.FeatureAIAutoFix, payload2.Capabilities) } for _, freeFeature := range license.TierFeatures[license.TierFree] { if !sliceContainsString(payload2.Capabilities, freeFeature) { t.Fatalf("expected free-tier feature %q to remain after expiry; got %v", freeFeature, payload2.Capabilities) } } }) t.Run("One_Trial_Per_Org_Enforcement", func(t *testing.T) { baseDir := t.TempDir() mtp := config.NewMultiTenantPersistence(baseDir) handlers := NewLicenseHandlers(mtp, false, &config.Config{PublicURL: "https://pulse.example.com"}) pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatalf("GenerateKey: %v", err) } handlers.trialRedeemer = func(token string) (*hostedTrialRedemptionResponse, error) { claims, err := pkglicensing.VerifyTrialActivationToken(token, pub, "", time.Now().UTC()) if err != nil { return nil, err } return issueTrialRedemptionResponse(t, priv, claims.OrgID, claims.InstanceHost, claims.Email, time.Now().UTC()), nil } t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub)) handlers.trialRedeemer = func(token string) (*hostedTrialRedemptionResponse, error) { claims, err := pkglicensing.VerifyTrialActivationToken(token, pub, "pulse.example.com", time.Now()) if err != nil { t.Fatalf("VerifyTrialActivationToken(redeemer): %v", err) } return issueTrialRedemptionResponse(t, priv, claims.OrgID, claims.InstanceHost, claims.Email, time.Now()), nil } org1 := "trial-org-1" ctx1 := context.WithValue(context.Background(), OrgIDContextKey, org1) returnURL := "https://pulse.example.com/auth/trial-activate" instanceToken1 := issueHostedLifecycleTrialInitiationToken(t, handlers, org1, returnURL) token1, err := pkglicensing.SignTrialActivationToken(priv, pkglicensing.TrialActivationClaims{ OrgID: org1, Email: "owner1@example.com", InstanceHost: "pulse.example.com", InstanceToken: instanceToken1, ReturnURL: returnURL, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), }, }) if err != nil { t.Fatalf("SignTrialActivationToken(org1): %v", err) } req1 := httptest.NewRequest(http.MethodGet, "/auth/trial-activate?token="+url.QueryEscape(token1), nil).WithContext(ctx1) req1.Host = "pulse.example.com" rec1 := httptest.NewRecorder() handlers.HandleTrialActivation(rec1, req1) if rec1.Code != http.StatusTemporaryRedirect { t.Fatalf("org1 first activation status=%d, want %d: %s", rec1.Code, http.StatusTemporaryRedirect, rec1.Body.String()) } token2, err := pkglicensing.SignTrialActivationToken(priv, pkglicensing.TrialActivationClaims{ OrgID: org1, Email: "owner1@example.com", InstanceHost: "pulse.example.com", InstanceToken: instanceToken1, ReturnURL: returnURL, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), }, }) if err != nil { t.Fatalf("SignTrialActivationToken(org1 second): %v", err) } req2 := httptest.NewRequest(http.MethodGet, "/auth/trial-activate?token="+url.QueryEscape(token2), nil).WithContext(ctx1) req2.Host = "pulse.example.com" rec2 := httptest.NewRecorder() handlers.HandleTrialActivation(rec2, req2) if rec2.Code != http.StatusTemporaryRedirect { t.Fatalf("org1 second activation status=%d, want %d: %s", rec2.Code, http.StatusTemporaryRedirect, rec2.Body.String()) } if got := rec2.Header().Get("Location"); got != "/settings/system-pro?trial=invalid" { t.Fatalf("org1 second activation redirect=%q, want %q", got, "/settings/system-pro?trial=invalid") } org2 := "trial-org-2" ctx2 := context.WithValue(context.Background(), OrgIDContextKey, org2) instanceToken2 := issueHostedLifecycleTrialInitiationToken(t, handlers, org2, returnURL) token3, err := pkglicensing.SignTrialActivationToken(priv, pkglicensing.TrialActivationClaims{ OrgID: org2, Email: "owner2@example.com", InstanceHost: "pulse.example.com", InstanceToken: instanceToken2, ReturnURL: returnURL, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), }, }) if err != nil { t.Fatalf("SignTrialActivationToken(org2): %v", err) } req3 := httptest.NewRequest(http.MethodGet, "/auth/trial-activate?token="+url.QueryEscape(token3), nil).WithContext(ctx2) req3.Host = "pulse.example.com" rec3 := httptest.NewRecorder() handlers.HandleTrialActivation(rec3, req3) if rec3.Code != http.StatusTemporaryRedirect { t.Fatalf("org2 activation status=%d, want %d: %s", rec3.Code, http.StatusTemporaryRedirect, rec3.Body.String()) } }) t.Run("Reaper_Cleanup_Cascade", func(t *testing.T) { baseDir := t.TempDir() mtp := config.NewMultiTenantPersistence(baseDir) orgID := "reap-org" if _, err := mtp.GetPersistence(orgID); err != nil { t.Fatalf("GetPersistence(%s) failed: %v", orgID, err) } requestedAt := time.Now().Add(-1 * time.Hour) if err := mtp.SaveOrganization(&models.Organization{ ID: orgID, Status: models.OrgStatusPendingDeletion, DisplayName: "Reap Org", OwnerUserID: "owner@example.com", CreatedAt: time.Now().UTC(), DeletionRequestedAt: &requestedAt, RetentionDays: 0, // immediate expiry }); err != nil { t.Fatalf("SaveOrganization(%s) failed: %v", orgID, err) } rbacProvider := NewTenantRBACProvider(baseDir) t.Cleanup(func() { _ = rbacProvider.Close() }) if _, err := rbacProvider.GetManager(orgID); err != nil { t.Fatalf("GetManager(%s) failed: %v", orgID, err) } if got := rbacProvider.ManagerCount(); got != 1 { t.Fatalf("ManagerCount()=%d, want 1", got) } router := &Router{rbacProvider: rbacProvider} reaper := hosted.NewReaper(mtp, mtp, time.Minute, true) reaper.OnBeforeDelete = func(tenant string) error { return router.CleanupTenant(context.Background(), tenant) } results := reaper.ScanOnce() if len(results) != 1 { t.Fatalf("ScanOnce results=%d, want 1 (%v)", len(results), results) } if results[0].OrgID != orgID { t.Fatalf("ScanOnce org_id=%q, want %q", results[0].OrgID, orgID) } if results[0].Action != "deleted" { t.Fatalf("ScanOnce action=%q, want %q", results[0].Action, "deleted") } if results[0].Error != nil { t.Fatalf("ScanOnce error=%v, want nil", results[0].Error) } // Verify org directory removed. orgDir := filepath.Join(baseDir, "orgs", orgID) if _, err := os.Stat(orgDir); err == nil || !os.IsNotExist(err) { t.Fatalf("expected org directory %s to be removed; stat err=%v", orgDir, err) } // Verify RBAC cleaned up (cached manager removed). if got := rbacProvider.ManagerCount(); got != 0 { t.Fatalf("ManagerCount() after cleanup=%d, want 0", got) } }) t.Run("Tenant_Rate_Limiting", func(t *testing.T) { trl := NewTenantRateLimiter(2, time.Minute) defer trl.Stop() h := TenantRateLimitMiddleware(trl)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) orgID := "test-rate-limit" for i := 1; i <= 2; i++ { rr := httptest.NewRecorder() h.ServeHTTP(rr, hostedLifecycleRequestWithOrg(orgID)) if rr.Code != http.StatusOK { t.Fatalf("request %d status=%d, want %d", i, rr.Code, http.StatusOK) } } rr := httptest.NewRecorder() h.ServeHTTP(rr, hostedLifecycleRequestWithOrg(orgID)) if rr.Code != http.StatusTooManyRequests { t.Fatalf("blocked request status=%d, want %d", rr.Code, http.StatusTooManyRequests) } if rr.Header().Get("X-RateLimit-Limit") != "2" { t.Fatalf("X-RateLimit-Limit=%q, want %q", rr.Header().Get("X-RateLimit-Limit"), "2") } if rr.Header().Get("X-RateLimit-Remaining") != "0" { t.Fatalf("X-RateLimit-Remaining=%q, want %q", rr.Header().Get("X-RateLimit-Remaining"), "0") } if rr.Header().Get("Retry-After") == "" { t.Fatal("Retry-After header should be set") } }) } func sliceContainsString(values []string, target string) bool { for _, v := range values { if v == target { return true } } return false } func hostedLifecycleRequestWithOrg(orgID string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/api/test", nil) ctx := context.WithValue(req.Context(), OrgIDContextKey, orgID) return req.WithContext(ctx) }