Pulse/internal/api/trial_handlers_test.go
2026-04-28 16:36:09 +01:00

337 lines
12 KiB
Go

package api
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"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"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func issueTrialEntitlementLease(t *testing.T, priv ed25519.PrivateKey, orgID, instanceHost, email string, now time.Time) string {
t.Helper()
trialState := pkglicensing.BuildTrialBillingState(now.UTC(), license.TierFeatures[license.TierPro])
token, err := pkglicensing.SignEntitlementLeaseToken(priv, pkglicensing.EntitlementLeaseClaims{
OrgID: orgID,
Email: email,
InstanceHost: instanceHost,
PlanVersion: trialState.PlanVersion,
SubscriptionState: trialState.SubscriptionState,
Capabilities: append([]string(nil), trialState.Capabilities...),
Limits: map[string]int64{},
MetersEnabled: []string{},
TrialStartedAt: trialState.TrialStartedAt,
TrialEndsAt: trialState.TrialEndsAt,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now.UTC()),
ExpiresAt: jwt.NewNumericDate(time.Unix(*trialState.TrialEndsAt, 0).UTC()),
Subject: "test_trial_entitlement",
},
})
if err != nil {
t.Fatalf("SignEntitlementLeaseToken: %v", err)
}
return token
}
func TestTrialStartRoute_RetiredFromSelfHostedRouter(t *testing.T) {
rawToken := "trial-start-retired-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsWrite}, nil)
router := NewRouter(newTestConfigWithTokens(t, record), nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodPost, "/api/license/trial/start", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
}
func TestTrialEntitlements_TrialDaysRemainingFromBillingState(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
h := NewLicenseHandlers(mtp, false)
orgID := "default"
store := config.NewFileBillingStore(baseDir)
now := time.Now()
startedAt := now.Add(-1 * time.Hour).Unix()
endsAt := now.Add(36 * time.Hour).Unix()
if err := store.SaveBillingState(orgID, &entitlements.BillingState{
Capabilities: append([]string(nil), license.TierFeatures[license.TierPro]...),
Limits: map[string]int64{},
MetersEnabled: []string{},
PlanVersion: "trial",
SubscriptionState: entitlements.SubStateTrial,
TrialStartedAt: &startedAt,
TrialEndsAt: &endsAt,
}); err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID)
req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil).WithContext(ctx)
rec := httptest.NewRecorder()
h.HandleEntitlements(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var payload EntitlementPayload
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.SubscriptionState != string(license.SubStateTrial) {
t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, license.SubStateTrial)
}
if payload.TrialExpiresAt == nil || payload.TrialDaysRemaining == nil {
t.Fatalf("expected trial fields, got expires_at=%v days=%v", payload.TrialExpiresAt, payload.TrialDaysRemaining)
}
if *payload.TrialExpiresAt != endsAt {
t.Fatalf("trial_expires_at=%d, want %d", *payload.TrialExpiresAt, endsAt)
}
// 36 hours => 2 days (ceil).
if *payload.TrialDaysRemaining != 2 {
t.Fatalf("trial_days_remaining=%d, want %d", *payload.TrialDaysRemaining, 2)
}
}
func TestRefreshHostedEntitlementLeaseOnce_RenewsLeaseAndKeepsLeaseOnlyState(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
refreshServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/entitlements/refresh" {
http.NotFound(w, r)
return
}
var req hostedTrialLeaseRefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode refresh request: %v", err)
}
if req.OrgID != "default" {
t.Fatalf("req.OrgID=%q, want %q", req.OrgID, "default")
}
if req.InstanceHost != "pulse.example.com" {
t.Fatalf("req.InstanceHost=%q, want %q", req.InstanceHost, "pulse.example.com")
}
if req.EntitlementRefreshToken != "etr_test_default" {
t.Fatalf("req.EntitlementRefreshToken=%q, want %q", req.EntitlementRefreshToken, "etr_test_default")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hostedTrialLeaseRefreshResponse{
EntitlementJWT: issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now()),
})
}))
defer refreshServer.Close()
h := NewLicenseHandlers(mtp, false, &config.Config{
PublicURL: "https://pulse.example.com",
ProTrialSignupURL: refreshServer.URL + "/start-pro-trial",
})
store := config.NewFileBillingStore(baseDir)
startedAt := time.Now().Add(-13 * 24 * time.Hour).Unix()
expiredLease := issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now().Add(-15*24*time.Hour))
if err := store.SaveBillingState("default", &entitlements.BillingState{
EntitlementJWT: expiredLease,
EntitlementRefreshToken: "etr_test_default",
TrialStartedAt: &startedAt,
}); err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
refreshed, permanent, err := h.refreshHostedEntitlementLeaseOnce("default", nil)
if err != nil {
t.Fatalf("refreshHostedEntitlementLeaseOnce: %v", err)
}
if !refreshed || permanent {
t.Fatalf("refreshed=%v permanent=%v, want refreshed=true permanent=false", refreshed, permanent)
}
state, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if state == nil || state.SubscriptionState != entitlements.SubStateTrial {
t.Fatalf("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 strings.TrimSpace(rawState.EntitlementJWT) == "" {
t.Fatal("expected entitlement_jwt ciphertext to be updated")
}
if rawState.EntitlementJWT == expiredLease {
t.Fatal("expected entitlement_jwt to be encrypted at rest")
}
if rawState.EntitlementRefreshToken == "" {
t.Fatal("expected persisted entitlement_refresh_token ciphertext")
}
if rawState.EntitlementRefreshToken == "etr_test_default" {
t.Fatal("expected entitlement_refresh_token to be encrypted at rest")
}
if rawState.SubscriptionState != "" {
t.Fatalf("raw subscription_state=%q, want empty", rawState.SubscriptionState)
}
if len(rawState.Capabilities) != 0 {
t.Fatalf("raw capabilities=%v, want empty", rawState.Capabilities)
}
}
func TestRefreshHostedEntitlementLeaseOnce_PermanentFailureClearsLocalEntitlement(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
refreshServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/entitlements/refresh" {
http.NotFound(w, r)
return
}
http.Error(w, "invalid entitlement refresh token", http.StatusUnauthorized)
}))
defer refreshServer.Close()
h := NewLicenseHandlers(mtp, false, &config.Config{
PublicURL: "https://pulse.example.com",
ProTrialSignupURL: refreshServer.URL + "/start-pro-trial",
})
store := config.NewFileBillingStore(baseDir)
startedAt := time.Now().Add(-2 * time.Hour).Unix()
activeLease := issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now())
if err := store.SaveBillingState("default", &entitlements.BillingState{
EntitlementJWT: activeLease,
EntitlementRefreshToken: "etr_test_default",
TrialStartedAt: &startedAt,
}); err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
refreshed, permanent, err := h.refreshHostedEntitlementLeaseOnce("default", nil)
if err == nil {
t.Fatal("expected refreshHostedEntitlementLeaseOnce to return an error")
}
if refreshed || !permanent {
t.Fatalf("refreshed=%v permanent=%v, want refreshed=false permanent=true", refreshed, permanent)
}
loaded, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if loaded == nil {
t.Fatal("expected billing state to remain after permanent refresh failure")
}
if loaded.SubscriptionState != entitlements.SubStateExpired {
t.Fatalf("subscription_state=%q, want %q", loaded.SubscriptionState, entitlements.SubStateExpired)
}
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.EntitlementJWT != "" {
t.Fatalf("raw entitlement_jwt=%q, want empty", rawState.EntitlementJWT)
}
if rawState.EntitlementRefreshToken != "" {
t.Fatalf("raw entitlement_refresh_token=%q, want empty", rawState.EntitlementRefreshToken)
}
if rawState.TrialStartedAt == nil || *rawState.TrialStartedAt <= 0 {
t.Fatalf("raw trial_started_at=%v, want non-nil positive timestamp", rawState.TrialStartedAt)
}
}
func TestRefreshHostedEntitlementLeaseOnce_HostMismatchLeavesStateUnchanged(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub))
refreshServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/entitlements/refresh" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hostedTrialLeaseRefreshResponse{
EntitlementJWT: issueTrialEntitlementLease(t, priv, "default", "pulse-b.example.com", "owner@example.com", time.Now()),
})
}))
defer refreshServer.Close()
h := NewLicenseHandlers(mtp, false, &config.Config{
PublicURL: "https://pulse.example.com",
ProTrialSignupURL: refreshServer.URL + "/start-pro-trial",
})
store := config.NewFileBillingStore(baseDir)
startedAt := time.Now().Add(-13 * 24 * time.Hour).Unix()
originalLease := issueTrialEntitlementLease(t, priv, "default", "pulse.example.com", "owner@example.com", time.Now().Add(-15*24*time.Hour))
if err := store.SaveBillingState("default", &entitlements.BillingState{
EntitlementJWT: originalLease,
EntitlementRefreshToken: "etr_test_default",
TrialStartedAt: &startedAt,
}); err != nil {
t.Fatalf("SaveBillingState: %v", err)
}
refreshed, permanent, err := h.refreshHostedEntitlementLeaseOnce("default", nil)
if err == nil {
t.Fatal("expected refreshHostedEntitlementLeaseOnce to fail on host mismatch")
}
if refreshed || permanent {
t.Fatalf("refreshed=%v permanent=%v, want refreshed=false permanent=false", refreshed, permanent)
}
state, err := store.GetBillingState("default")
if err != nil {
t.Fatalf("GetBillingState: %v", err)
}
if state == nil {
t.Fatal("expected billing state to remain present")
}
if state.EntitlementJWT != originalLease {
t.Fatal("expected original entitlement_jwt to remain unchanged after host mismatch")
}
}