mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Fix activation fingerprint and license period status
This commit is contained in:
parent
f850099134
commit
847cf98544
8 changed files with 439 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ var grantContractJSONTags = []string{
|
|||
"feat",
|
||||
"max_guests",
|
||||
"max_users",
|
||||
"current_period_end",
|
||||
"grace_until",
|
||||
"email",
|
||||
"jti",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue