mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 19:56:01 +00:00
6929c1c8fb
Go half of the licensing-policy client work from the v6 upgrade sweep
(license findings 5 and 6); the copy and docs landed in 3a51429a5 and
the license-server side lands in pulse-pro.
The grant refresh loop treated every HTTP 401 as revocation and wiped
the licence and activation file immediately, but the server returns 401
for token-not-found and token-expired as well as revocation, so a
single spurious 401 dropped a paying customer to Community until a
successful re-exchange. Activation state is now cleared only when the
401 carries an explicit revocation code (TOKEN_REVOKED,
INSTALLATION_REVOKED, LICENSE_REVOKED); any other 401 keeps the licence
and retries, letting the grant's 72h expiry plus the 7-day grace window
govern degradation. The bump_license_version immediate-refresh path in
the revocation poller had the same any-401 wipe and gets the same
guard.
ClassifyLegacyExchangeError now maps a 409 MAX_INSTALLATIONS exchange
error to failed/exchange_installation_limit with the
free_installation_slot action instead of the retryable
pending/exchange_conflict state, since retrying can never succeed until
a slot is freed server-side. Other 409s keep the pending conflict
semantics.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
73 lines
2.6 KiB
Go
73 lines
2.6 KiB
Go
package licensing
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestClassifyLegacyExchangeError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantState CommercialMigrationState
|
|
wantReason CommercialMigrationReason
|
|
wantAction CommercialMigrationAction
|
|
}{
|
|
{
|
|
name: "retryable server error stays pending",
|
|
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 503, Code: "unavailable", Retryable: true}),
|
|
wantState: CommercialMigrationStatePending,
|
|
wantReason: CommercialMigrationReasonExchangeUnavailable,
|
|
},
|
|
{
|
|
name: "rate limit stays pending",
|
|
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 429, Code: "rate_limited"}),
|
|
wantState: CommercialMigrationStatePending,
|
|
wantReason: CommercialMigrationReasonExchangeRateLimited,
|
|
},
|
|
{
|
|
name: "invalid token is terminal",
|
|
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 401, Code: "invalid_legacy_token"}),
|
|
wantState: CommercialMigrationStateFailed,
|
|
wantReason: CommercialMigrationReasonExchangeInvalid,
|
|
},
|
|
{
|
|
name: "unsupported key format is terminal",
|
|
err: fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license"),
|
|
wantState: CommercialMigrationStateFailed,
|
|
wantReason: CommercialMigrationReasonExchangeUnsupportedKey,
|
|
},
|
|
{
|
|
name: "installation limit conflict is terminal with slot guidance",
|
|
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "MAX_INSTALLATIONS"}),
|
|
wantState: CommercialMigrationStateFailed,
|
|
wantReason: CommercialMigrationReasonExchangeInstallationLimit,
|
|
wantAction: CommercialMigrationActionFreeInstallationSlot,
|
|
},
|
|
{
|
|
name: "other conflict stays pending",
|
|
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 409, Code: "EXCHANGE_IN_PROGRESS"}),
|
|
wantState: CommercialMigrationStatePending,
|
|
wantReason: CommercialMigrationReasonExchangeConflict,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ClassifyLegacyExchangeError(tt.err)
|
|
if got == nil {
|
|
t.Fatal("expected non-nil migration status")
|
|
}
|
|
if got.State != tt.wantState {
|
|
t.Fatalf("state=%q, want %q", got.State, tt.wantState)
|
|
}
|
|
if got.Reason != tt.wantReason {
|
|
t.Fatalf("reason=%q, want %q", got.Reason, tt.wantReason)
|
|
}
|
|
if tt.wantAction != "" && got.RecommendedAction != tt.wantAction {
|
|
t.Fatalf("action=%q, want %q", got.RecommendedAction, tt.wantAction)
|
|
}
|
|
})
|
|
}
|
|
}
|