mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
337 lines
12 KiB
Go
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")
|
|
}
|
|
}
|