Files
pulse/pkg/licensing/commercial_migration_test.go
T
rcourtman 6929c1c8fb fix(licensing): clear activation only on explicit revocation codes, not any 401
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>
2026-06-11 08:34:26 +01:00

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)
}
})
}
}