From 847cf985440e23bd0cdc353fc153fb5ca04c1e29 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 9 Jul 2026 11:19:40 +0100 Subject: [PATCH] Fix activation fingerprint and license period status --- .../v6/internal/subsystems/cloud-paid.md | 23 ++- pkg/licensing/activation_store.go | 71 ++++++++++ pkg/licensing/activation_store_test.go | 116 +++++++++++++++ pkg/licensing/activation_types.go | 47 ++++--- pkg/licensing/grant_claims_contract_test.go | 1 + pkg/licensing/models.go | 37 +++++ pkg/licensing/service.go | 46 ++++-- pkg/licensing/service_activate_test.go | 133 ++++++++++++++++++ 8 files changed, 439 insertions(+), 35 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9f65f0547..06f169f9f 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1500,10 +1500,15 @@ That same local-persistence boundary also owns the filesystem path contract for commercial secrets at rest. `pkg/licensing/persistence.go` and `pkg/licensing/activation_store.go` must normalize the owned config directory once and resolve only the fixed `.license-key`, `license.enc`, and -`activation.enc` leaves through the shared storage-path helper before any -filesystem read, write, rename, stat, or delete. Future licensing persistence -changes must not bypass that resolver with raw `filepath.Join(configDir, ...)` -joins or introduce caller-controlled persistence filenames. +`activation.enc`, and `instance-fingerprint` leaves through the shared +storage-path helper before any filesystem read, write, rename, stat, or delete. +Future licensing persistence changes must not bypass that resolver with raw +`filepath.Join(configDir, ...)` joins or introduce caller-controlled +persistence filenames. The `instance-fingerprint` file is not an activation +credential and must survive clear-license flows: native activation and legacy +exchange must reuse it as the stable local installation identity so one machine +does not consume another paid installation slot after a local activation clear, +container recreation, or retry. That same local-persistence boundary also owns writable-but-not-owned runtime storage semantics for commercial state. `pkg/licensing/persistence.go` may harden directories it owns to `0700`, but it must not assume it can chmod the @@ -1551,7 +1556,15 @@ unmetered. Activation-grant translation is part of the same boundary: when relay/license server grants enter the local claims model, Cloud plan keys and lifecycle state must still resolve through the canonical entitlement claim accessors rather -than becoming a parallel truth path. +than becoming a parallel truth path. The activation grant `exp` remains the +short-lived enforcement and refresh lease; recurring self-hosted license period +display must come from the optional grant `current_period_end` mapped into the +local claims model and exposed through `/api/license/status` `expires_at` and +`days_remaining`. Older grants that omit `current_period_end` may fall back to +the grant/JWT expiry for compatibility, but new recurring grants must not make +the UI present the 72-hour relay lease as the customer's subscription end date. +The shared grant wire shape must stay aligned across the Pulse client, the +`pulse-pro` license server issuer, and the relay-server grant validator. The legacy-license exchange transport is part of that same activation boundary: `pkg/licensing/activation_types.go` and `pkg/licensing/license_server_client.go` must treat `legacy_license_token` as the canonical v6 request field for diff --git a/pkg/licensing/activation_store.go b/pkg/licensing/activation_store.go index d1234190b..05128502d 100644 --- a/pkg/licensing/activation_store.go +++ b/pkg/licensing/activation_store.go @@ -5,11 +5,82 @@ import ( "encoding/json" "fmt" "os" + "strings" ) // ActivationStateFileName is the name of the encrypted activation state file. const ActivationStateFileName = "activation.enc" +// InstanceFingerprintFileName is the durable installation identity used for +// activation-key installs. It is intentionally separate from activation.enc so +// clearing a license does not make one machine consume another install slot. +const InstanceFingerprintFileName = "instance-fingerprint" + +const maxInstanceFingerprintFileSize = 512 + +// LoadOrCreateInstanceFingerprint returns the stable local installation +// fingerprint, creating it if this config directory has not activated before. +func (p *Persistence) LoadOrCreateInstanceFingerprint() (string, error) { + fingerprintPath, err := resolvePersistencePath(p.configDir, InstanceFingerprintFileName) + if err != nil { + return "", fmt.Errorf("resolve instance fingerprint path: %w", err) + } + + data, err := readBoundedPersistenceRegularFile(fingerprintPath, maxInstanceFingerprintFileSize) + if err == nil { + fingerprint := strings.TrimSpace(string(data)) + if fingerprint == "" { + return "", fmt.Errorf("instance fingerprint file is empty") + } + if err := os.Chmod(fingerprintPath, persistencePrivateFilePerm); err != nil { + return "", fmt.Errorf("secure instance fingerprint file: %w", err) + } + return fingerprint, nil + } + if !isMissingPersistencePathError(err) { + return "", fmt.Errorf("read instance fingerprint file: %w", err) + } + + state, err := p.LoadActivationState() + if err != nil { + return "", fmt.Errorf("load activation state for instance fingerprint: %w", err) + } + if state != nil { + fingerprint := strings.TrimSpace(state.InstanceFingerprint) + if fingerprint != "" { + if err := p.SaveInstanceFingerprint(fingerprint); err != nil { + return "", err + } + return fingerprint, nil + } + } + + fingerprint, err := generateFingerprint() + if err != nil { + return "", fmt.Errorf("generate instance fingerprint: %w", err) + } + if err := p.SaveInstanceFingerprint(fingerprint); err != nil { + return "", err + } + return fingerprint, nil +} + +// SaveInstanceFingerprint persists the stable local installation fingerprint. +func (p *Persistence) SaveInstanceFingerprint(fingerprint string) error { + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return fmt.Errorf("instance fingerprint cannot be empty") + } + fingerprintPath, err := resolvePersistencePath(p.configDir, InstanceFingerprintFileName) + if err != nil { + return fmt.Errorf("resolve instance fingerprint path: %w", err) + } + if err := writeOwnerOnlyPersistenceFileAtomic(fingerprintPath, []byte(fingerprint+"\n")); err != nil { + return fmt.Errorf("write instance fingerprint file: %w", err) + } + return nil +} + // SaveActivationState encrypts and persists the activation state to disk. func (p *Persistence) SaveActivationState(state *ActivationState) error { if state == nil { diff --git a/pkg/licensing/activation_store_test.go b/pkg/licensing/activation_store_test.go index d174cd83d..bc5a9903c 100644 --- a/pkg/licensing/activation_store_test.go +++ b/pkg/licensing/activation_store_test.go @@ -253,3 +253,119 @@ func TestActivationStatePersistence(t *testing.T) { } }) } + +func TestInstanceFingerprintPersistenceSurvivesActivationClear(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-fingerprint-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + p, err := NewPersistence(tmpDir) + if err != nil { + t.Fatalf("create persistence: %v", err) + } + + first, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint first: %v", err) + } + if first == "" { + t.Fatal("expected non-empty fingerprint") + } + + second, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint second: %v", err) + } + if second != first { + t.Fatalf("fingerprint changed between reads: %q then %q", first, second) + } + + state := &ActivationState{ + InstallationID: "inst_abc", + InstallationToken: "pit_live_secret", + LicenseID: "lic_123", + GrantJWT: "header.payload.signature", + GrantJTI: "grant_xyz", + GrantExpiresAt: 1700000000, + InstanceFingerprint: first, + LicenseServerURL: "https://license.example.com", + ActivatedAt: 1699000000, + LastRefreshedAt: 1699500000, + } + if err := p.SaveActivationState(state); err != nil { + t.Fatalf("SaveActivationState: %v", err) + } + if err := p.ClearActivationState(); err != nil { + t.Fatalf("ClearActivationState: %v", err) + } + + afterClear, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err) + } + if afterClear != first { + t.Fatalf("fingerprint changed after activation clear: %q then %q", first, afterClear) + } + + fingerprintPath := filepath.Join(tmpDir, InstanceFingerprintFileName) + info, err := os.Stat(fingerprintPath) + if err != nil { + t.Fatalf("stat fingerprint file: %v", err) + } + if got := info.Mode().Perm(); got != 0600 { + t.Fatalf("fingerprint file perms = %o, want 600", got) + } + if p.ActivationStateExists() { + t.Fatal("activation state should still be cleared") + } +} + +func TestInstanceFingerprintPersistenceSeedsFromExistingActivationState(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "pulse-fingerprint-seed-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + p, err := NewPersistence(tmpDir) + if err != nil { + t.Fatalf("create persistence: %v", err) + } + + state := &ActivationState{ + InstallationID: "inst_existing", + InstallationToken: "pit_live_existing", + LicenseID: "lic_existing", + GrantJWT: "header.payload.signature", + GrantJTI: "grant_existing", + GrantExpiresAt: 1700000000, + InstanceFingerprint: "existing-fingerprint", + LicenseServerURL: "https://license.example.com", + ActivatedAt: 1699000000, + LastRefreshedAt: 1699500000, + } + if err := p.SaveActivationState(state); err != nil { + t.Fatalf("SaveActivationState: %v", err) + } + + fingerprint, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint: %v", err) + } + if fingerprint != state.InstanceFingerprint { + t.Fatalf("fingerprint=%q want existing activation fingerprint %q", fingerprint, state.InstanceFingerprint) + } + + if err := p.ClearActivationState(); err != nil { + t.Fatalf("ClearActivationState: %v", err) + } + afterClear, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err) + } + if afterClear != state.InstanceFingerprint { + t.Fatalf("fingerprint after clear=%q want %q", afterClear, state.InstanceFingerprint) + } +} diff --git a/pkg/licensing/activation_types.go b/pkg/licensing/activation_types.go index bb7e335db..aa8d3f16d 100644 --- a/pkg/licensing/activation_types.go +++ b/pkg/licensing/activation_types.go @@ -54,22 +54,23 @@ func grantClaimsUseUncappedCoreMonitoring(gc *GrantClaims) bool { // GrantClaims are the claims parsed from a relay grant JWT payload. // The grant is a short-lived JWT (72h TTL) issued by the license server. type GrantClaims struct { - Issuer string `json:"iss"` - Audience string `json:"aud"` - LicenseID string `json:"lid"` - InstallationID string `json:"iid"` - LicenseVersion int64 `json:"lv"` - State string `json:"st"` // active|past_due|grace - Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc. - PlanKey string `json:"plan"` - Features []string `json:"feat"` - MaxGuests int `json:"max_guests"` - MaxUsers int `json:"max_users"` - IssuedAt int64 `json:"iat"` - ExpiresAt int64 `json:"exp"` - GraceUntil int64 `json:"grace_until"` - JTI string `json:"jti"` // unique grant ID - Email string `json:"email"` // license owner email + Issuer string `json:"iss"` + Audience string `json:"aud"` + LicenseID string `json:"lid"` + InstallationID string `json:"iid"` + LicenseVersion int64 `json:"lv"` + State string `json:"st"` // active|past_due|grace + Tier string `json:"tier"` // matches Tier constants: "relay", "pro", "pro_plus", etc. + PlanKey string `json:"plan"` + Features []string `json:"feat"` + MaxGuests int `json:"max_guests"` + MaxUsers int `json:"max_users"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` + CurrentPeriodEnd int64 `json:"current_period_end,omitempty"` + GraceUntil int64 `json:"grace_until"` + JTI string `json:"jti"` // unique grant ID + Email string `json:"email"` // license owner email } func (g *GrantClaims) UnmarshalJSON(data []byte) error { @@ -94,6 +95,7 @@ func grantClaimsToClaimsWithContinuity(gc *GrantClaims, _ ActivationContinuity) Tier: Tier(gc.Tier), IssuedAt: gc.IssuedAt, ExpiresAt: gc.ExpiresAt, + LicensePeriodEnd: gc.CurrentPeriodEnd, Features: gc.Features, MaxGuests: gc.MaxGuests, MaxUsers: gc.MaxUsers, @@ -218,12 +220,13 @@ type ActivateInstallationResponse struct { // ActivateResponseLicense is the license portion of the activation response. type ActivateResponseLicense struct { - LicenseID string `json:"license_id"` - State string `json:"state"` - Tier string `json:"tier"` - MaxGuests int `json:"max_guests"` - Features []string `json:"features"` - LicenseVersion int64 `json:"license_version"` + LicenseID string `json:"license_id"` + State string `json:"state"` + Tier string `json:"tier"` + MaxGuests int `json:"max_guests"` + Features []string `json:"features"` + LicenseVersion int64 `json:"license_version"` + CurrentPeriodEnd string `json:"current_period_end,omitempty"` } func (l *ActivateResponseLicense) UnmarshalJSON(data []byte) error { diff --git a/pkg/licensing/grant_claims_contract_test.go b/pkg/licensing/grant_claims_contract_test.go index f26b9a290..2c5cccc5f 100644 --- a/pkg/licensing/grant_claims_contract_test.go +++ b/pkg/licensing/grant_claims_contract_test.go @@ -174,6 +174,7 @@ var grantContractJSONTags = []string{ "feat", "max_guests", "max_users", + "current_period_end", "grace_until", "email", "jti", diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index 6a921455e..05dd88ad8 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -22,6 +22,11 @@ type Claims struct { // Expires at (Unix timestamp, 0 for lifetime) ExpiresAt int64 `json:"exp,omitempty"` + // LicensePeriodEnd is the paid entitlement period end. Activation grants use + // a short ExpiresAt lease for enforcement; this field preserves the billing + // period shown in status responses when the grant carries it. + LicensePeriodEnd int64 `json:"license_period_end,omitempty"` + // Features explicitly granted (optional, tier implies features) Features []string `json:"features,omitempty"` @@ -154,6 +159,38 @@ func (l *License) ExpiresAt() *time.Time { return &t } +// LicensePeriodDaysRemaining returns days remaining for user-facing license +// status. It uses the paid entitlement period when present and falls back to +// the grant/JWT expiration for legacy licenses and older grants. +func (l *License) LicensePeriodDaysRemaining() int { + if l.IsLifetime() { + return -1 + } + expiresAt := l.Claims.LicensePeriodEnd + if expiresAt == 0 { + expiresAt = l.Claims.ExpiresAt + } + remaining := time.Until(time.Unix(expiresAt, 0)) + if remaining < 0 { + return 0 + } + return int(remaining.Hours() / 24) +} + +// LicensePeriodExpiresAt returns the user-facing license period end. Short +// activation grant expiry remains available through ExpiresAt for enforcement. +func (l *License) LicensePeriodExpiresAt() *time.Time { + if l.IsLifetime() { + return nil + } + expiresAt := l.Claims.LicensePeriodEnd + if expiresAt == 0 { + expiresAt = l.Claims.ExpiresAt + } + t := time.Unix(expiresAt, 0) + return &t +} + // HasFeature checks if the license grants a specific feature. func (l *License) HasFeature(feature string) bool { for _, capability := range l.Claims.EffectiveCapabilities() { diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index 7bfcc5380..3f7ab5c73 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -248,10 +248,9 @@ func (s *Service) ActivateWithKey(activationKey string) (*License, error) { return nil, fmt.Errorf("activation unavailable: license server client not configured") } - // Generate a stable fingerprint for this installation. - fingerprint, err := generateFingerprint() + fingerprint, err := activationInstanceFingerprint(persistence) if err != nil { - return nil, fmt.Errorf("generate instance fingerprint: %w", err) + return nil, err } hostname, _ := os.Hostname() @@ -287,9 +286,9 @@ func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, erro return nil, fmt.Errorf("activation unavailable: license server client not configured") } - fingerprint, err := generateFingerprint() + fingerprint, err := activationInstanceFingerprint(persistence) if err != nil { - return nil, fmt.Errorf("generate instance fingerprint: %w", err) + return nil, err } hostname, _ := os.Hostname() @@ -314,6 +313,21 @@ func (s *Service) ActivateLegacyLicense(legacyLicenseKey string) (*License, erro }) } +func activationInstanceFingerprint(persistence *Persistence) (string, error) { + if persistence != nil { + fingerprint, err := persistence.LoadOrCreateInstanceFingerprint() + if err != nil { + return "", fmt.Errorf("load instance fingerprint: %w", err) + } + return fingerprint, nil + } + fingerprint, err := generateFingerprint() + if err != nil { + return "", fmt.Errorf("generate instance fingerprint: %w", err) + } + return fingerprint, nil +} + func (s *Service) applyActivationResponse( resp *ActivateInstallationResponse, fingerprint string, @@ -447,12 +461,19 @@ func (s *Service) RestoreActivation(state *ActivationState) error { source := NewTokenSource(&s.license.Claims) s.evaluator = NewEvaluator(source) s.activationState = &stateCopy + persistence := s.persistence cb := s.onLicenseChange activationCB := s.onActivationStateChange snapshot := cloneLicense(s.license) stateSnapshot := cloneActivationState(&stateCopy) s.mu.Unlock() + if persistence != nil && stateCopy.InstanceFingerprint != "" { + if err := persistence.SaveInstanceFingerprint(stateCopy.InstanceFingerprint); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to persist instance fingerprint: %v\n", err) + } + } + if cb != nil { cb(snapshot) } @@ -475,6 +496,10 @@ func (s *Service) Clear() { s.evaluator = nil persistence := s.persistence hadActivation := s.activationState != nil + var activationFingerprint string + if s.activationState != nil { + activationFingerprint = s.activationState.InstanceFingerprint + } s.activationState = nil cb := s.onLicenseChange activationCB := s.onActivationStateChange @@ -482,6 +507,11 @@ func (s *Service) Clear() { // Clear persisted activation state if it existed. if hadActivation && persistence != nil { + if activationFingerprint != "" { + if err := persistence.SaveInstanceFingerprint(activationFingerprint); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to persist instance fingerprint before clearing activation: %v\n", err) + } + } if err := persistence.ClearActivationState(); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to clear activation state: %v\n", err) } @@ -703,15 +733,15 @@ func (s *Service) Status() *LicenseStatus { status.Tier = s.license.Claims.Tier status.PlanVersion = s.license.Claims.EntitlementPlanVersion() status.IsLifetime = s.license.IsLifetime() - status.DaysRemaining = s.license.DaysRemaining() + status.DaysRemaining = s.license.LicensePeriodDaysRemaining() status.Features = s.license.AllFeatures() if maxGuests, ok := s.license.Claims.EffectiveLimits()["max_guests"]; ok { status.MaxGuests = safeIntFromInt64(maxGuests) } - if s.license.ExpiresAt() != nil { - exp := s.license.ExpiresAt().Format(time.RFC3339) + if s.license.LicensePeriodExpiresAt() != nil { + exp := s.license.LicensePeriodExpiresAt().Format(time.RFC3339) status.ExpiresAt = &exp } diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index e791cb6d8..5f8a2bd87 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -179,6 +179,139 @@ func TestServiceActivateWithKey_SendsClientVersion(t *testing.T) { } } +func TestServiceActivateWithKey_ReusesPersistentFingerprintAfterClear(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + setupTestPublicKey(t) + + tmpDir, err := os.MkdirTemp("", "pulse-service-fingerprint-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + p, err := NewPersistence(tmpDir) + if err != nil { + t.Fatalf("create persistence: %v", err) + } + + var fingerprints []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/activate" { + t.Fatalf("Path = %q, want /v1/activate", r.URL.Path) + } + + var req ActivateInstallationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + fingerprints = append(fingerprints, req.InstanceFingerprint) + if req.InstanceFingerprint == "" { + t.Fatal("expected non-empty instance fingerprint") + } + + grantJWT := makeTestGrantJWT(t, &GrantClaims{ + LicenseID: "lic_fingerprint", + Tier: "pro", + State: "active", + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + }) + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(ActivateInstallationResponse{ + License: ActivateResponseLicense{ + LicenseID: "lic_fingerprint", + State: "active", + Tier: "pro", + }, + Installation: ActivateResponseInstallation{ + InstallationID: "inst_fingerprint", + InstallationToken: "pit_live_fingerprint", + Status: "active", + }, + Grant: GrantEnvelope{ + JWT: grantJWT, + JTI: "grant_fingerprint", + ExpiresAt: time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339), + }, + }) + })) + defer server.Close() + + svc := NewService() + svc.SetPersistence(p) + svc.SetLicenseServerClient(NewLicenseServerClient(server.URL)) + + if _, err := svc.Activate("ppk_live_fingerprint_test"); err != nil { + t.Fatalf("first Activate: %v", err) + } + svc.Clear() + persistedAfterClear, err := p.LoadOrCreateInstanceFingerprint() + if err != nil { + t.Fatalf("LoadOrCreateInstanceFingerprint after clear: %v", err) + } + if persistedAfterClear != fingerprints[0] { + t.Fatalf("clear did not preserve activation fingerprint: got %q want %q", persistedAfterClear, fingerprints[0]) + } + if _, err := svc.Activate("ppk_live_fingerprint_test"); err != nil { + t.Fatalf("second Activate: %v", err) + } + + if len(fingerprints) != 2 { + t.Fatalf("expected two activation requests, got %d", len(fingerprints)) + } + if fingerprints[1] != fingerprints[0] { + t.Fatalf("activation fingerprint changed after clear: %q then %q", fingerprints[0], fingerprints[1]) + } +} + +func TestServiceStatusUsesLicensePeriodEndForActivationGrantDisplay(t *testing.T) { + now := time.Now().UTC() + grantExpiresAt := now.Add(72 * time.Hour).Unix() + periodEnd := now.Add(365 * 24 * time.Hour).Unix() + + lic := grantClaimsToLicense(&GrantClaims{ + LicenseID: "lic_period_display", + Tier: "pro", + State: "active", + IssuedAt: now.Unix(), + ExpiresAt: grantExpiresAt, + CurrentPeriodEnd: periodEnd, + Email: "period@example.com", + Features: []string{"relay"}, + LicenseVersion: 1, + InstallationID: "inst_period_display", + JTI: "grant_period_display", + }, "header.payload.signature") + if lic.Claims.ExpiresAt != grantExpiresAt { + t.Fatalf("grant expiry changed: got %d want %d", lic.Claims.ExpiresAt, grantExpiresAt) + } + if lic.Claims.LicensePeriodEnd != periodEnd { + t.Fatalf("license period end = %d, want %d", lic.Claims.LicensePeriodEnd, periodEnd) + } + + svc := NewService() + svc.SetCurrentForTesting(lic) + + status := svc.Status() + if !status.Valid { + t.Fatal("expected active grant to be valid") + } + if status.ExpiresAt == nil { + t.Fatal("expected status expires_at") + } + wantExpires := time.Unix(periodEnd, 0).Format(time.RFC3339) + if *status.ExpiresAt != wantExpires { + t.Fatalf("status expires_at = %q, want %q", *status.ExpiresAt, wantExpires) + } + if status.DaysRemaining < 360 { + t.Fatalf("status days_remaining = %d, want billing-period scale", status.DaysRemaining) + } + if gotGrantDays := lic.DaysRemaining(); gotGrantDays > 3 { + t.Fatalf("grant DaysRemaining() = %d, want short grant lease", gotGrantDays) + } +} + func TestServiceActivate_AllowsLegacyJWTInDevMode(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "true")