package licensing import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestNewLicenseServerClient(t *testing.T) { t.Run("explicit URL", func(t *testing.T) { c := NewLicenseServerClient("https://custom.example.com/") if c.BaseURL() != "https://custom.example.com" { t.Errorf("BaseURL = %q, want trailing slash stripped", c.BaseURL()) } }) t.Run("default URL when empty", func(t *testing.T) { // Clear env to test default. t.Setenv("PULSE_LICENSE_SERVER_URL", "") c := NewLicenseServerClient("") if c.BaseURL() != DefaultLicenseServerURL { t.Errorf("BaseURL = %q, want %q", c.BaseURL(), DefaultLicenseServerURL) } }) t.Run("env override", func(t *testing.T) { t.Setenv("PULSE_LICENSE_SERVER_URL", "https://env.example.com") c := NewLicenseServerClient("") want := DefaultLicenseServerURL if allowLicenseServerURLEnvOverride() { want = "https://env.example.com" } if c.BaseURL() != want { t.Errorf("BaseURL = %q, want %q", c.BaseURL(), want) } }) t.Run("rejects insecure non loopback url", func(t *testing.T) { c := NewLicenseServerClient("http://192.168.1.25:8081") if c.BaseURL() != "" { t.Fatalf("BaseURL = %q, want empty when URL is rejected", c.BaseURL()) } _, err := c.GetCheckoutSessionResult(context.Background(), "cs_test_123") if err == nil || !strings.Contains(err.Error(), "must use https unless host is loopback") { t.Fatalf("expected insecure URL error, got %v", err) } }) } func TestClientActivate(t *testing.T) { t.Run("successful activation", func(t *testing.T) { grantJWT := makeTestGrantJWT(t, &GrantClaims{ LicenseID: "lic_test", Tier: "pro", State: "active", IssuedAt: 1000, ExpiresAt: 2000, }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Method = %q, want POST", r.Method) } if r.URL.Path != "/v1/activate" { t.Errorf("Path = %q, want /v1/activate", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) } if r.Header.Get("Idempotency-Key") == "" { t.Error("missing Idempotency-Key header") } var req ActivateInstallationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("decode request: %v", err) } if req.ActivationKey != "ppk_live_test123" { t.Errorf("ActivationKey = %q, want ppk_live_test123", req.ActivationKey) } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(ActivateInstallationResponse{ License: ActivateResponseLicense{ LicenseID: "lic_test", State: "active", Tier: "pro", }, Installation: ActivateResponseInstallation{ InstallationID: "inst_abc", InstallationToken: "pit_live_token", Status: "active", }, Grant: GrantEnvelope{ JWT: grantJWT, JTI: "grant_123", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), }, RefreshPolicy: RefreshHints{ IntervalSeconds: 21600, JitterPercent: 0.2, }, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) resp, err := client.Activate(context.Background(), ActivateInstallationRequest{ ActivationKey: "ppk_live_test123", InstanceFingerprint: "fp-123", }) if err != nil { t.Fatalf("Activate failed: %v", err) } if resp.Installation.InstallationID != "inst_abc" { t.Errorf("InstallationID = %q, want inst_abc", resp.Installation.InstallationID) } if resp.Installation.InstallationToken != "pit_live_token" { t.Errorf("InstallationToken = %q, want pit_live_token", resp.Installation.InstallationToken) } if resp.Grant.JTI != "grant_123" { t.Errorf("Grant.JTI = %q, want grant_123", resp.Grant.JTI) } }) t.Run("server returns structured error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "code": "invalid_key", "message": "Activation key not found", "retryable": false, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) _, err := client.Activate(context.Background(), ActivateInstallationRequest{ ActivationKey: "ppk_live_bad", }) if err == nil { t.Fatal("expected error") } apiErr, ok := err.(*LicenseServerError) if !ok { t.Fatalf("expected *LicenseServerError, got %T", err) } if apiErr.StatusCode != 400 { t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode) } if apiErr.Code != "invalid_key" { t.Errorf("Code = %q, want invalid_key", apiErr.Code) } if apiErr.Retryable { t.Error("expected Retryable=false for 400") } }) t.Run("server returns legacy error field", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{ "error": "invalid_activation", "message": "Activation key could not be redeemed", "retryable": false, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) _, err := client.Activate(context.Background(), ActivateInstallationRequest{ ActivationKey: "ppk_live_bad", }) if err == nil { t.Fatal("expected error") } apiErr, ok := err.(*LicenseServerError) if !ok { t.Fatalf("expected *LicenseServerError, got %T", err) } if apiErr.StatusCode != 400 { t.Errorf("StatusCode = %d, want 400", apiErr.StatusCode) } if apiErr.Code != "invalid_activation" { t.Errorf("Code = %q, want invalid_activation", apiErr.Code) } if apiErr.Message != "Activation key could not be redeemed" { t.Errorf("Message = %q, want Activation key could not be redeemed", apiErr.Message) } if apiErr.Retryable { t.Error("expected Retryable=false for 400") } }) t.Run("server error is retryable", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() client := NewLicenseServerClient(server.URL) _, err := client.Activate(context.Background(), ActivateInstallationRequest{ ActivationKey: "ppk_live_test", }) if err == nil { t.Fatal("expected error") } apiErr, ok := err.(*LicenseServerError) if !ok { t.Fatalf("expected *LicenseServerError, got %T", err) } if !apiErr.Retryable { t.Error("expected 500 errors to be retryable") } }) t.Run("context cancellation", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Slow response — never completes <-r.Context().Done() })) defer server.Close() client := NewLicenseServerClient(server.URL) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately _, err := client.Activate(ctx, ActivateInstallationRequest{ ActivationKey: "ppk_live_test", }) if err == nil { t.Fatal("expected error from canceled context") } }) } func TestClientExchangeLegacyLicense(t *testing.T) { t.Run("successful exchange", func(t *testing.T) { grantJWT := makeTestGrantJWT(t, &GrantClaims{ LicenseID: "lic_legacy", Tier: "lifetime", State: "active", IssuedAt: 1000, ExpiresAt: 2000, }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Method = %q, want POST", r.Method) } if r.URL.Path != "/v1/licenses/exchange" { t.Errorf("Path = %q, want /v1/licenses/exchange", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) } if r.Header.Get("Idempotency-Key") == "" { t.Error("missing Idempotency-Key header") } var raw map[string]any if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { t.Errorf("decode request: %v", err) } body, err := json.Marshal(raw) if err != nil { t.Fatalf("re-marshal request: %v", err) } var req ExchangeLegacyLicenseRequest if err := json.Unmarshal(body, &req); err != nil { t.Fatalf("decode compatibility request: %v", err) } if req.LegacyLicenseKey != "header.payload.signature" { t.Errorf("LegacyLicenseKey = %q, want header.payload.signature", req.LegacyLicenseKey) } if got := raw["legacy_license_token"]; got != "header.payload.signature" { t.Errorf("legacy_license_token = %v, want header.payload.signature", got) } if _, hasLegacyKey := raw["legacy_license_key"]; hasLegacyKey { t.Error("legacy_license_key should not be sent by client") } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(ActivateInstallationResponse{ License: ActivateResponseLicense{ LicenseID: "lic_legacy", State: "active", Tier: "lifetime", }, Installation: ActivateResponseInstallation{ InstallationID: "inst_legacy", InstallationToken: "pit_live_legacy", Status: "active", }, Grant: GrantEnvelope{ JWT: grantJWT, JTI: "grant_legacy", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), }, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) resp, err := client.ExchangeLegacyLicense(context.Background(), ExchangeLegacyLicenseRequest{ LegacyLicenseKey: "header.payload.signature", InstanceFingerprint: "fp-legacy", }) if err != nil { t.Fatalf("ExchangeLegacyLicense failed: %v", err) } if resp.Installation.InstallationID != "inst_legacy" { t.Errorf("InstallationID = %q, want inst_legacy", resp.Installation.InstallationID) } if resp.Grant.JTI != "grant_legacy" { t.Errorf("Grant.JTI = %q, want grant_legacy", resp.Grant.JTI) } }) } func TestClientGetCheckoutSessionResult(t *testing.T) { t.Run("successful checkout session lookup", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("Method = %q, want GET", r.Method) } if r.URL.Path != "/v1/checkout/session" { t.Errorf("Path = %q, want /v1/checkout/session", r.URL.Path) } if got := r.URL.Query().Get("session_id"); got != "cs_test_123" { t.Errorf("session_id = %q, want cs_test_123", got) } if r.Header.Get("Accept") != "application/json" { t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept")) } w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(CheckoutSessionResult{ Status: "fulfilled", CheckoutStatus: "complete", PaymentStatus: "paid", PurchaseReturnJTI: "purchase_return_jti_123", ActivationKeyPrefix: "ppk_live_", Tier: "pro", }) })) defer server.Close() client := NewLicenseServerClient(server.URL) result, err := client.GetCheckoutSessionResult(context.Background(), " cs_test_123 ") if err != nil { t.Fatalf("GetCheckoutSessionResult failed: %v", err) } if result.Status != "fulfilled" { t.Fatalf("Status = %q, want fulfilled", result.Status) } if result.CheckoutStatus != "complete" { t.Fatalf("CheckoutStatus = %q, want complete", result.CheckoutStatus) } if result.PurchaseReturnJTI != "purchase_return_jti_123" { t.Fatalf("PurchaseReturnJTI = %q, want purchase_return_jti_123", result.PurchaseReturnJTI) } }) t.Run("empty session id fails before transport", func(t *testing.T) { client := NewLicenseServerClient("https://license.example.com") _, err := client.GetCheckoutSessionResult(context.Background(), " ") if err == nil { t.Fatal("expected error") } if err.Error() != "checkout session id is required" { t.Fatalf("err = %q, want checkout session id is required", err.Error()) } }) } func TestClientCreateCheckoutPortalHandoff(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Method = %q, want POST", r.Method) } if r.URL.Path != "/v1/checkout/portal-handoff" { t.Errorf("Path = %q, want /v1/checkout/portal-handoff", r.URL.Path) } if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Content-Type = %q, want application/json", r.Header.Get("Content-Type")) } if r.Header.Get("Accept") != "application/json" { t.Errorf("Accept = %q, want application/json", r.Header.Get("Accept")) } var req CheckoutPortalHandoffRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode request: %v", err) } if req.Feature != "max_monitored_systems" { t.Fatalf("Feature = %q, want max_monitored_systems", req.Feature) } if req.PurchaseReturnJTI != "purchase_return_jti_123" { t.Fatalf("PurchaseReturnJTI = %q, want purchase_return_jti_123", req.PurchaseReturnJTI) } w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(CheckoutPortalHandoffResponse{ PortalHandoffID: "cph_test_123", Feature: "max_monitored_systems", ExpiresAt: 1_775_000_000, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) result, err := client.CreateCheckoutPortalHandoff(context.Background(), CheckoutPortalHandoffRequest{ Feature: "max_monitored_systems", SuccessURL: "https://pulse.example.com/auth/license-purchase-activate?purchase_return_token=prt_signed&session_id={CHECKOUT_SESSION_ID}", CancelURL: "https://pulse.example.com/settings/system/billing/plan?intent=max_monitored_systems&purchase=cancelled", PurchaseReturnJTI: "purchase_return_jti_123", }) if err != nil { t.Fatalf("CreateCheckoutPortalHandoff failed: %v", err) } if result.PortalHandoffID != "cph_test_123" { t.Fatalf("PortalHandoffID = %q, want cph_test_123", result.PortalHandoffID) } if result.Feature != "max_monitored_systems" { t.Fatalf("Feature = %q, want max_monitored_systems", result.Feature) } } func TestClientRefreshGrant(t *testing.T) { t.Run("successful refresh", func(t *testing.T) { newGrantJWT := makeTestGrantJWT(t, &GrantClaims{ LicenseID: "lic_test", Tier: "pro", State: "active", IssuedAt: 3000, ExpiresAt: 4000, }) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Method = %q, want POST", r.Method) } if r.URL.Path != "/v1/grants/refresh" { t.Errorf("Path = %q, want /v1/grants/refresh", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer pit_live_token" { t.Errorf("Authorization = %q, want Bearer pit_live_token", got) } json.NewEncoder(w).Encode(RefreshGrantResponse{ Grant: GrantEnvelope{ JWT: newGrantJWT, JTI: "grant_456", ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), }, }) })) defer server.Close() client := NewLicenseServerClient(server.URL) resp, err := client.RefreshGrant(context.Background(), "inst_abc", "pit_live_token", RefreshGrantRequest{ InstallationID: "inst_abc", InstanceFingerprint: "fp-123", CurrentGrantJTI: "grant_123", }) if err != nil { t.Fatalf("RefreshGrant failed: %v", err) } if resp.Grant.JTI != "grant_456" { t.Errorf("Grant.JTI = %q, want grant_456", resp.Grant.JTI) } }) t.Run("401 returns structured error", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]any{ "code": "token_revoked", "message": "Installation token has been revoked", }) })) defer server.Close() client := NewLicenseServerClient(server.URL) _, err := client.RefreshGrant(context.Background(), "inst_abc", "bad_token", RefreshGrantRequest{}) if err == nil { t.Fatal("expected error") } apiErr, ok := err.(*LicenseServerError) if !ok { t.Fatalf("expected *LicenseServerError, got %T", err) } if apiErr.StatusCode != 401 { t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode) } if apiErr.Code != "token_revoked" { t.Errorf("Code = %q, want token_revoked", apiErr.Code) } }) }