mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Split runtime capabilities from commercial entitlements
This commit is contained in:
parent
e4fa4824b9
commit
f8ad57a1f2
106 changed files with 2907 additions and 1013 deletions
|
|
@ -84,6 +84,22 @@ type EntitlementPayload struct {
|
|||
CommercialMigration *CommercialMigrationStatus `json:"commercial_migration,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeCapabilitiesPayload is the canonical non-commercial license contract
|
||||
// for feature gating and runtime retention/limit decisions.
|
||||
type RuntimeCapabilitiesPayload struct {
|
||||
// Capabilities lists all granted capability keys.
|
||||
Capabilities []string `json:"capabilities"`
|
||||
|
||||
// Limits lists quantitative limits with current usage.
|
||||
Limits []LimitStatus `json:"limits"`
|
||||
|
||||
// HostedMode indicates that this server is running in Pulse hosted mode.
|
||||
HostedMode bool `json:"hosted_mode"`
|
||||
|
||||
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
|
||||
MaxHistoryDays int `json:"max_history_days"`
|
||||
}
|
||||
|
||||
// LimitStatus represents a quantitative limit with current usage state.
|
||||
type LimitStatus struct {
|
||||
// Key is the limit identifier (e.g., "max_monitored_systems").
|
||||
|
|
@ -141,6 +157,35 @@ func BuildEntitlementPayload(status *LicenseStatus, subscriptionState string) En
|
|||
return BuildEntitlementPayloadWithUsage(status, subscriptionState, EntitlementUsageSnapshot{}, nil)
|
||||
}
|
||||
|
||||
// BuildRuntimeCapabilitiesPayload constructs the canonical non-commercial
|
||||
// runtime capability payload from LicenseStatus.
|
||||
func BuildRuntimeCapabilitiesPayload(
|
||||
status *LicenseStatus,
|
||||
subscriptionState string,
|
||||
) RuntimeCapabilitiesPayload {
|
||||
return BuildRuntimeCapabilitiesPayloadWithUsage(
|
||||
status,
|
||||
subscriptionState,
|
||||
EntitlementUsageSnapshot{},
|
||||
)
|
||||
}
|
||||
|
||||
// BuildRuntimeCapabilitiesPayloadWithUsage constructs the canonical
|
||||
// non-commercial runtime capability payload from LicenseStatus and observed usage.
|
||||
func BuildRuntimeCapabilitiesPayloadWithUsage(
|
||||
status *LicenseStatus,
|
||||
subscriptionState string,
|
||||
usage EntitlementUsageSnapshot,
|
||||
) RuntimeCapabilitiesPayload {
|
||||
entitlementPayload := BuildEntitlementPayloadWithUsage(status, subscriptionState, usage, nil)
|
||||
return RuntimeCapabilitiesPayload{
|
||||
Capabilities: append([]string(nil), entitlementPayload.Capabilities...),
|
||||
Limits: append([]LimitStatus(nil), entitlementPayload.Limits...),
|
||||
HostedMode: entitlementPayload.HostedMode,
|
||||
MaxHistoryDays: entitlementPayload.MaxHistoryDays,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildEntitlementPayloadWithUsage constructs the normalized payload from LicenseStatus and observed usage.
|
||||
func BuildEntitlementPayloadWithUsage(
|
||||
status *LicenseStatus,
|
||||
|
|
|
|||
|
|
@ -240,6 +240,45 @@ func TestBuildEntitlementPayload_NilStatus_MaxHistoryDays(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildRuntimeCapabilitiesPayloadWithUsage_CurrentValues(t *testing.T) {
|
||||
status := &LicenseStatus{
|
||||
Valid: true,
|
||||
Tier: TierRelay,
|
||||
Features: append([]string(nil), TierFeatures[TierRelay]...),
|
||||
MaxMonitoredSystems: 12,
|
||||
}
|
||||
|
||||
payload := BuildRuntimeCapabilitiesPayloadWithUsage(status, "", EntitlementUsageSnapshot{
|
||||
MonitoredSystems: 5,
|
||||
})
|
||||
|
||||
if !reflect.DeepEqual(payload.Capabilities, status.Features) {
|
||||
t.Fatalf("expected capabilities to match status features")
|
||||
}
|
||||
if payload.MaxHistoryDays != TierHistoryDays[TierRelay] {
|
||||
t.Fatalf("expected relay max history days %d, got %d", TierHistoryDays[TierRelay], payload.MaxHistoryDays)
|
||||
}
|
||||
if len(payload.Limits) != 1 {
|
||||
t.Fatalf("expected one runtime limit, got %d", len(payload.Limits))
|
||||
}
|
||||
if payload.Limits[0].Key != MaxMonitoredSystemsLicenseGateKey {
|
||||
t.Fatalf("expected runtime limit key %q, got %q", MaxMonitoredSystemsLicenseGateKey, payload.Limits[0].Key)
|
||||
}
|
||||
if payload.Limits[0].Current != 5 {
|
||||
t.Fatalf("expected runtime current 5, got %d", payload.Limits[0].Current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRuntimeCapabilitiesPayload_NilStatusDefaultsToFreeTier(t *testing.T) {
|
||||
payload := BuildRuntimeCapabilitiesPayload(nil, "")
|
||||
if payload.MaxHistoryDays != TierHistoryDays[TierFree] {
|
||||
t.Fatalf("expected MaxHistoryDays=%d for nil status, got %d", TierHistoryDays[TierFree], payload.MaxHistoryDays)
|
||||
}
|
||||
if len(payload.Capabilities) != 0 {
|
||||
t.Fatalf("expected no runtime capabilities for nil status, got %+v", payload.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -20,6 +21,21 @@ type LicenseServerClient struct {
|
|||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// CheckoutSessionResult is the canonical purchase-fulfillment response returned
|
||||
// by the commercial checkout session endpoint.
|
||||
type CheckoutSessionResult struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
CheckoutStatus string `json:"checkout_status,omitempty"`
|
||||
PaymentStatus string `json:"payment_status,omitempty"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
ActivationKey string `json:"activation_key,omitempty"`
|
||||
ActivationKeyPrefix string `json:"activation_key_prefix,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
PlanKey string `json:"plan_key,omitempty"`
|
||||
}
|
||||
|
||||
// NewLicenseServerClient creates a client for the license server.
|
||||
// The base URL defaults to DefaultLicenseServerURL. The PULSE_LICENSE_SERVER_URL
|
||||
// env var override is only allowed in non-release builds.
|
||||
|
|
@ -140,6 +156,42 @@ func (c *LicenseServerClient) RefreshGrant(ctx context.Context, installationID,
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
// GetCheckoutSessionResult resolves a commercial checkout session into its
|
||||
// authoritative fulfillment payload.
|
||||
func (c *LicenseServerClient) GetCheckoutSessionResult(ctx context.Context, sessionID string) (*CheckoutSessionResult, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, fmt.Errorf("checkout session id is required")
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.baseURL+"/v1/checkout/session?session_id="+url.QueryEscape(sessionID),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create checkout session result request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checkout session result request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, c.parseError(resp)
|
||||
}
|
||||
|
||||
var result CheckoutSessionResult
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode checkout session result response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// BootstrapQuickstart exchanges a runtime identity for a short-lived
|
||||
// quickstart token and the current authoritative quickstart inventory.
|
||||
// Callers must pass a server-verified quickstart authority token as
|
||||
|
|
|
|||
|
|
@ -270,6 +270,58 @@ func TestClientExchangeLegacyLicense(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
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",
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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 TestClientRefreshGrant(t *testing.T) {
|
||||
t.Run("successful refresh", func(t *testing.T) {
|
||||
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue