mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Clarify v5 migration recovery states
This commit is contained in:
@@ -157,6 +157,13 @@ canonical, but the retired rc.1 through rc.5 `/infrastructure`, `/workloads`,
|
||||
- If you are upgrading directly from v5, start from the familiar platform pages
|
||||
rather than looking for the temporary unified pages from early v6 RCs.
|
||||
|
||||
### Configuration Compatibility
|
||||
|
||||
Pulse v6 honors the legacy `PORT` environment variable as a deprecated fallback
|
||||
only when `FRONTEND_PORT` is unset, so existing installs keep their listener
|
||||
port after upgrade. Move deployments to `FRONTEND_PORT`; when both variables
|
||||
are set, `FRONTEND_PORT` wins.
|
||||
|
||||
### API Changes
|
||||
|
||||
Unified Resources is now the canonical model and endpoint family:
|
||||
|
||||
@@ -97,6 +97,13 @@ that binary, not separate customer-facing agent products.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
Commercial v5-to-v6 migration retry state may pass through `internal/api/`
|
||||
handlers that agent-lifecycle also references, but the ownership remains
|
||||
API/cloud-paid. Agent lifecycle surfaces may observe paid-migration posture for
|
||||
upgrade continuity, but they must not reinterpret `commercial_migration`
|
||||
reasons, reset `first_failed_at`, change the license-server retry cadence, or
|
||||
turn blocked license egress into an agent update or enrollment state.
|
||||
|
||||
`/api/connections` command-policy comparison is lifecycle-adjacent fleet
|
||||
truth. The desired side is the effective runtime config served to the agent
|
||||
after token scope and binding checks, not the unsanitized profile desire. If a
|
||||
|
||||
@@ -139,6 +139,16 @@ product API routes free of maintainer commercial analytics.
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
Commercial migration payloads are a shared API/cloud-paid contract. The
|
||||
license-server client must preserve canonical v6 nested error-envelope codes
|
||||
such as `RENEWED_KEY_AVAILABLE`, and commercial posture payloads may carry
|
||||
`commercial_migration.first_failed_at` so the backend can distinguish a short
|
||||
transport outage from sustained blocked egress without changing retry cadence.
|
||||
Browser API types and presentation helpers must treat `exchange_stale_key` plus
|
||||
`retrieve_current_key` and `exchange_connectivity_required` plus
|
||||
`allow_license_egress` as explicit migration states rather than inferring them
|
||||
from HTTP status alone.
|
||||
|
||||
`GET /api/connections` consumes the agent desired-config contract for fleet
|
||||
governance. When it derives `fleet.commandPolicy`, `fleet.configDrift`, and
|
||||
rollout state, it must compare the agent-applied report with the effective
|
||||
|
||||
@@ -257,6 +257,17 @@ avoids a cloud-control-plane report data path across clients.
|
||||
pending must self-retry in the background with backoff for the life of
|
||||
the process so a transient license-server or DNS failure at first boot
|
||||
never strands a paying upgrader on Community until a manual restart.
|
||||
Signature-valid v5 JWTs that are expired beyond the v5 grace window but
|
||||
correspond to a newer retrievable server-side key or live entitlement must
|
||||
classify as terminal stale-key recovery (`exchange_stale_key` with
|
||||
`retrieve_current_key`) rather than generic invalid-key failure; malformed,
|
||||
signature-invalid, and truly lapsed keys must keep the generic terminal
|
||||
rejection path. Transport-level legacy-exchange failures must preserve the
|
||||
first continuous failure timestamp on `commercial_migration.first_failed_at`
|
||||
and, after 24 hours, keep retrying while surfacing
|
||||
`exchange_connectivity_required` with the outbound
|
||||
`license.pulserelay.pro` connectivity policy instead of rendering ordinary
|
||||
pending copy forever.
|
||||
6. `internal/api/licensing_legacy_retry.go` shared with `api-contracts`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership.
|
||||
7. `internal/api/payments_webhook_handlers.go` shared with `api-contracts`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
|
||||
8. `internal/api/public_signup_handlers.go` shared with `api-contracts`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
|
||||
|
||||
@@ -63,6 +63,13 @@ state.
|
||||
|
||||
## Extension Points
|
||||
|
||||
Commercial v5-to-v6 migration posture may be carried by `internal/api/`
|
||||
handlers that storage/recovery references for setup or support-adjacent flows,
|
||||
but it remains API/cloud-paid state. Storage and recovery surfaces may point
|
||||
operators at the v6 upgrade guide when license egress is blocked, but they must
|
||||
not reinterpret `commercial_migration` as backup coverage, restore readiness,
|
||||
or storage-health evidence, and must not mutate `first_failed_at`.
|
||||
|
||||
Mobile onboarding reads exposed through `internal/api/onboarding_handlers.go`
|
||||
are storage/recovery-adjacent only as hosted recovery/support handoff
|
||||
surfaces. Recovery code may consume the API-owned
|
||||
|
||||
@@ -70,6 +70,31 @@ describe('LicenseAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves commercial migration timing fields from entitlements', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
tier: 'free',
|
||||
subscription_state: 'expired',
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
upgrade_reasons: [],
|
||||
commercial_migration: {
|
||||
state: 'pending',
|
||||
reason: 'exchange_connectivity_required',
|
||||
recommended_action: 'allow_license_egress',
|
||||
first_failed_at: 1_700_000_000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await LicenseAPI.getCommercialEntitlements();
|
||||
|
||||
expect(result.commercial_migration).toMatchObject({
|
||||
state: 'pending',
|
||||
reason: 'exchange_connectivity_required',
|
||||
recommended_action: 'allow_license_egress',
|
||||
first_failed_at: 1_700_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads commercial posture from the public-safe commercial endpoint', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({
|
||||
tier: 'pro',
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface CommercialMigrationStatus {
|
||||
state?: string;
|
||||
reason?: string;
|
||||
recommended_action?: string;
|
||||
first_failed_at?: number;
|
||||
}
|
||||
|
||||
// Mirrors internal/api/subscription_entitlements.go:RuntimeCapabilitiesPayload
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, For, Show } from 'solid-js';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { InlineNotice, type InlineNoticeTone } from '@/components/shared/InlineNotice';
|
||||
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
|
||||
import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements';
|
||||
import {
|
||||
@@ -104,6 +105,9 @@ const formatDate = (value?: string | null) => {
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const commercialMigrationNoticeTone = (notice: Notice): InlineNoticeTone =>
|
||||
notice.tone.includes('red-') ? 'danger' : 'warning';
|
||||
|
||||
const statusStateClass = (state: 'active' | 'partial' | 'missing') => {
|
||||
switch (state) {
|
||||
case 'active':
|
||||
@@ -385,10 +389,10 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
</Show>
|
||||
<Show when={props.commercialMigrationNotice}>
|
||||
{(notice) => (
|
||||
<div class={`mb-4 rounded-md border p-3 text-sm ${notice().tone}`}>
|
||||
<InlineNotice tone={commercialMigrationNoticeTone(notice())} class="mb-4">
|
||||
<p class="font-medium">{notice().title}</p>
|
||||
<p class="mt-1 text-xs opacity-90">{notice().body}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs">{notice().body}</p>
|
||||
</InlineNotice>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.grandfatheredPriceNotice}>
|
||||
|
||||
@@ -1527,6 +1527,67 @@ describe('ProLicensePanel', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows retrieve-license guidance when a v5 key has been superseded', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
subscription_state: 'expired',
|
||||
upgrade_reasons: [],
|
||||
tier: 'free',
|
||||
trial_eligible: false,
|
||||
commercial_migration: {
|
||||
source: 'v5_license',
|
||||
state: 'failed',
|
||||
reason: 'exchange_stale_key',
|
||||
recommended_action: 'retrieve_current_key',
|
||||
},
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
|
||||
const title = screen.getByText('v5 license migration needs attention');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title.closest('.border-red-300')).not.toBeNull();
|
||||
expect(screen.getByText(/superseded by a renewal/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pulserelay\.pro\/retrieve-license/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows blocked-egress guidance after sustained exchange transport failure', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
subscription_state: 'expired',
|
||||
upgrade_reasons: [],
|
||||
tier: 'free',
|
||||
trial_eligible: false,
|
||||
commercial_migration: {
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_connectivity_required',
|
||||
recommended_action: 'allow_license_egress',
|
||||
first_failed_at: 1_700_000_000,
|
||||
},
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
|
||||
const title = screen.getByText('v5 license migration pending');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(title.closest('.border-amber-300')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/paid v6 features require periodic outbound HTTPS/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/allow outbound HTTPS to license\.pulserelay\.pro/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Pro license split into shell, runtime, and plan owners', () => {
|
||||
expect(proLicensePanelSource).toContain('./useProLicensePanelState');
|
||||
expect(proLicensePanelSource).toContain('sessionPresentationPolicyResolved');
|
||||
|
||||
@@ -205,6 +205,53 @@ describe('licensePresentation', () => {
|
||||
expect(notice?.body).not.toContain('still settling');
|
||||
});
|
||||
|
||||
it('points superseded v5 keys at current-key retrieval instead of retrying', () => {
|
||||
const notice = getCommercialMigrationNotice({
|
||||
state: 'failed',
|
||||
reason: 'exchange_stale_key',
|
||||
recommended_action: 'retrieve_current_key',
|
||||
} as never);
|
||||
expect(notice).toMatchObject({
|
||||
title: 'v5 license migration needs attention',
|
||||
tone: expect.stringContaining('red'),
|
||||
});
|
||||
expect(notice?.body).toContain('superseded by a renewal');
|
||||
expect(notice?.body).toContain('pulserelay.pro/retrieve-license');
|
||||
expect(notice?.body).not.toContain('Retry with the original v5 Pro/Lifetime key');
|
||||
});
|
||||
|
||||
it('keeps generic rejected-key copy distinct while offering safe license retrieval', () => {
|
||||
const notice = getCommercialMigrationNotice({
|
||||
state: 'failed',
|
||||
reason: 'exchange_invalid',
|
||||
recommended_action: 'enter_supported_v5_key',
|
||||
} as never);
|
||||
expect(notice).toMatchObject({
|
||||
title: 'v5 license migration needs attention',
|
||||
tone: expect.stringContaining('red'),
|
||||
});
|
||||
expect(notice?.body).toContain('rejected during v6 migration');
|
||||
expect(notice?.body).toContain('pulserelay.pro/retrieve-license');
|
||||
expect(notice?.body).toContain('Retry with the original v5 Pro/Lifetime key');
|
||||
});
|
||||
|
||||
it('renders sustained transport failure as blocked-egress policy, not ordinary pending', () => {
|
||||
const notice = getCommercialMigrationNotice({
|
||||
state: 'pending',
|
||||
reason: 'exchange_connectivity_required',
|
||||
recommended_action: 'allow_license_egress',
|
||||
} as never);
|
||||
expect(notice).toMatchObject({
|
||||
title: 'v5 license migration pending',
|
||||
tone: expect.stringContaining('amber'),
|
||||
});
|
||||
expect(notice?.body).toContain('over a day');
|
||||
expect(notice?.body).toContain('Paid v6 features require periodic outbound HTTPS');
|
||||
expect(notice?.body).toContain('Core monitoring keeps running');
|
||||
expect(notice?.body).toContain('license.pulserelay.pro');
|
||||
expect(notice?.body).toContain('docs/UPGRADE_v6.md');
|
||||
});
|
||||
|
||||
it('renders an unreadable persisted v5 license as terminal with re-enter-key guidance', () => {
|
||||
const notice = getCommercialMigrationNotice({
|
||||
state: 'failed',
|
||||
|
||||
@@ -1037,6 +1037,10 @@ export const getCommercialMigrationActionText = (action?: string): string => {
|
||||
return 'Retry with the original v5 Pro/Lifetime key from this instance.';
|
||||
case 'free_installation_slot':
|
||||
return 'Contact support@pulserelay.pro to release an installation you no longer use or to raise the limit.';
|
||||
case 'retrieve_current_key':
|
||||
return 'Retrieve your current key at pulserelay.pro/retrieve-license and paste it here.';
|
||||
case 'allow_license_egress':
|
||||
return 'Allow outbound HTTPS to license.pulserelay.pro, then Pulse will keep retrying automatically.';
|
||||
default:
|
||||
return 'Review the plan state from this instance before trying again.';
|
||||
}
|
||||
@@ -1060,6 +1064,10 @@ export const getCommercialMigrationNotice = (
|
||||
body =
|
||||
'Pulse detected a paid v5 license, but another v6 license handoff is still settling.';
|
||||
break;
|
||||
case 'exchange_connectivity_required':
|
||||
body =
|
||||
'Pulse has not been able to reach license.pulserelay.pro for over a day. Paid v6 features require periodic outbound HTTPS to that host. Core monitoring keeps running; paid features stay on Community until connectivity is allowed. See docs/UPGRADE_v6.md for the connectivity policy.';
|
||||
break;
|
||||
case 'exchange_unavailable':
|
||||
default:
|
||||
break;
|
||||
@@ -1079,7 +1087,12 @@ export const getCommercialMigrationNotice = (
|
||||
'Pulse detected a paid v5 license, but that key is already active on its maximum number of v6 installations, so this instance cannot activate until a slot is freed.';
|
||||
break;
|
||||
case 'exchange_invalid':
|
||||
body = 'Pulse detected a paid v5 license, but that key was rejected during v6 migration.';
|
||||
body =
|
||||
'Pulse detected a paid v5 license, but that key was rejected during v6 migration. Retrieve your current key at pulserelay.pro/retrieve-license if this purchase is still active.';
|
||||
break;
|
||||
case 'exchange_stale_key':
|
||||
body =
|
||||
'Pulse detected a paid v5 license, but this key has been superseded by a renewal.';
|
||||
break;
|
||||
case 'exchange_malformed':
|
||||
body = 'Pulse detected a v5-looking key, but it is malformed and cannot be migrated.';
|
||||
|
||||
@@ -133,6 +133,32 @@ func TestContract_AlertDeliveryDiagnosisPayloadShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_CommercialMigrationPayloadCarriesFailureTiming(t *testing.T) {
|
||||
status := pkglicensing.CommercialMigrationStatus{
|
||||
Source: pkglicensing.CommercialMigrationSourceV5License,
|
||||
State: pkglicensing.CommercialMigrationStatePending,
|
||||
Reason: pkglicensing.CommercialMigrationReasonExchangeConnectivity,
|
||||
RecommendedAction: pkglicensing.CommercialMigrationActionAllowLicenseEgress,
|
||||
FirstFailedAt: 1_700_000_000,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal commercial migration status: %v", err)
|
||||
}
|
||||
body := string(payload)
|
||||
for _, field := range []string{
|
||||
`"state":"pending"`,
|
||||
`"reason":"exchange_connectivity_required"`,
|
||||
`"recommended_action":"allow_license_egress"`,
|
||||
`"first_failed_at":1700000000`,
|
||||
} {
|
||||
if !strings.Contains(body, field) {
|
||||
t.Fatalf("commercial migration payload missing %s in %s", field, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_ReportSchedulePayloadShape(t *testing.T) {
|
||||
nextRun := time.Date(2026, 7, 8, 6, 0, 0, 0, time.UTC)
|
||||
schedule := config.ReportSchedule{
|
||||
|
||||
@@ -100,6 +100,13 @@ func cloneCommercialMigrationStatusFromLicensing(state *commercialMigrationStatu
|
||||
return pkglicensing.CloneCommercialMigrationStatus(state)
|
||||
}
|
||||
|
||||
func applyCommercialMigrationFailureTimingFromLicensing(
|
||||
status, previous *commercialMigrationStatusModel,
|
||||
now time.Time,
|
||||
) *commercialMigrationStatusModel {
|
||||
return pkglicensing.ApplyCommercialMigrationFailureTiming(status, previous, now)
|
||||
}
|
||||
|
||||
func classifyLegacyExchangeErrorFromLicensing(err error) *commercialMigrationStatusModel {
|
||||
return pkglicensing.ClassifyLegacyExchangeError(err)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ type LicenseHandlers struct {
|
||||
legacyExchangeRetries sync.Map // map[string]struct{}
|
||||
// legacyExchangeRetrySchedule overrides the retry backoff in tests.
|
||||
legacyExchangeRetrySchedule []time.Duration
|
||||
// commercialMigrationNow overrides migration timing in tests.
|
||||
commercialMigrationNow func() time.Time
|
||||
}
|
||||
|
||||
// NewLicenseHandlers creates a new license handlers instance.
|
||||
@@ -799,7 +801,15 @@ func (h *LicenseHandlers) setCommercialMigrationState(orgID string, status *comm
|
||||
}
|
||||
}
|
||||
|
||||
existing.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(status)
|
||||
now := time.Now
|
||||
if h.commercialMigrationNow != nil {
|
||||
now = h.commercialMigrationNow
|
||||
}
|
||||
existing.CommercialMigration = applyCommercialMigrationFailureTimingFromLicensing(
|
||||
status,
|
||||
existing.CommercialMigration,
|
||||
now(),
|
||||
)
|
||||
return billingStore.SaveBillingState(orgID, existing)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,51 @@ func TestGetTenantComponents_AutoExchangesPersistedLegacyJWT(t *testing.T) {
|
||||
handlers.StopAllBackgroundLoops()
|
||||
}
|
||||
|
||||
func TestSetCommercialMigrationState_AutoMigrateEscalatesSustainedTransportFailure(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(baseDir)
|
||||
handlers := NewLicenseHandlers(mtp, false)
|
||||
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
handlers.commercialMigrationNow = func() time.Time { return now }
|
||||
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
|
||||
Source: pkglicensing.CommercialMigrationSourceV5License,
|
||||
State: pkglicensing.CommercialMigrationStatePending,
|
||||
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
|
||||
}); err != nil {
|
||||
t.Fatalf("set initial migration state: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second)
|
||||
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
|
||||
Source: pkglicensing.CommercialMigrationSourceV5License,
|
||||
State: pkglicensing.CommercialMigrationStatePending,
|
||||
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
|
||||
}); err != nil {
|
||||
t.Fatalf("set sustained migration state: %v", err)
|
||||
}
|
||||
|
||||
store := config.NewFileBillingStore(baseDir)
|
||||
state, err := store.GetBillingState("default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBillingState sustained: %v", err)
|
||||
}
|
||||
if state == nil || state.CommercialMigration == nil {
|
||||
t.Fatal("expected sustained commercial migration state")
|
||||
}
|
||||
if state.CommercialMigration.FirstFailedAt != 1_700_000_000 {
|
||||
t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt)
|
||||
}
|
||||
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity {
|
||||
t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity)
|
||||
}
|
||||
if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress {
|
||||
t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTenantComponents_SkipsExchange_WhenActivationStateExists(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
||||
|
||||
|
||||
@@ -286,3 +286,62 @@ func TestGetTenantComponents_SurfacesUnreadablePersistedLicense(t *testing.T) {
|
||||
t.Fatalf("commercial_migration.reason=%q, want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonPersistedUnreadable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCommercialMigrationState_EscalatesSustainedTransportFailure(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(baseDir)
|
||||
handlers := NewLicenseHandlers(mtp, false)
|
||||
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
handlers.commercialMigrationNow = func() time.Time { return now }
|
||||
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
|
||||
Source: pkglicensing.CommercialMigrationSourceV5License,
|
||||
State: pkglicensing.CommercialMigrationStatePending,
|
||||
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
|
||||
}); err != nil {
|
||||
t.Fatalf("set initial migration state: %v", err)
|
||||
}
|
||||
|
||||
store := config.NewFileBillingStore(baseDir)
|
||||
state, err := store.GetBillingState("default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBillingState initial: %v", err)
|
||||
}
|
||||
if state == nil || state.CommercialMigration == nil {
|
||||
t.Fatal("expected initial commercial migration state")
|
||||
}
|
||||
if state.CommercialMigration.FirstFailedAt != now.Unix() {
|
||||
t.Fatalf("first_failed_at=%d want %d", state.CommercialMigration.FirstFailedAt, now.Unix())
|
||||
}
|
||||
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeUnavailable {
|
||||
t.Fatalf("initial reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeUnavailable)
|
||||
}
|
||||
|
||||
now = now.Add(time.Duration(pkglicensing.CommercialMigrationSustainedExchangeUnavailableSeconds+1) * time.Second)
|
||||
if err := handlers.setCommercialMigrationState("default", &pkglicensing.CommercialMigrationStatus{
|
||||
Source: pkglicensing.CommercialMigrationSourceV5License,
|
||||
State: pkglicensing.CommercialMigrationStatePending,
|
||||
Reason: pkglicensing.CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: pkglicensing.CommercialMigrationActionRetryActivation,
|
||||
}); err != nil {
|
||||
t.Fatalf("set sustained migration state: %v", err)
|
||||
}
|
||||
|
||||
state, err = store.GetBillingState("default")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBillingState sustained: %v", err)
|
||||
}
|
||||
if state == nil || state.CommercialMigration == nil {
|
||||
t.Fatal("expected sustained commercial migration state")
|
||||
}
|
||||
if state.CommercialMigration.FirstFailedAt != 1_700_000_000 {
|
||||
t.Fatalf("sustained first_failed_at=%d want 1700000000", state.CommercialMigration.FirstFailedAt)
|
||||
}
|
||||
if state.CommercialMigration.Reason != pkglicensing.CommercialMigrationReasonExchangeConnectivity {
|
||||
t.Fatalf("sustained reason=%q want %q", state.CommercialMigration.Reason, pkglicensing.CommercialMigrationReasonExchangeConnectivity)
|
||||
}
|
||||
if state.CommercialMigration.RecommendedAction != pkglicensing.CommercialMigrationActionAllowLicenseEgress {
|
||||
t.Fatalf("sustained action=%q want %q", state.CommercialMigration.RecommendedAction, pkglicensing.CommercialMigrationActionAllowLicenseEgress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package licensing
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CommercialMigrationSource string
|
||||
@@ -25,13 +26,19 @@ const (
|
||||
CommercialMigrationReasonExchangeRevoked CommercialMigrationReason = "exchange_revoked"
|
||||
CommercialMigrationReasonExchangeNonMigratable CommercialMigrationReason = "exchange_non_migratable"
|
||||
CommercialMigrationReasonExchangeUnsupportedKey CommercialMigrationReason = "exchange_unsupported"
|
||||
CommercialMigrationReasonExchangeStaleKey CommercialMigrationReason = "exchange_stale_key"
|
||||
CommercialMigrationReasonExchangeConnectivity CommercialMigrationReason = "exchange_connectivity_required"
|
||||
|
||||
CommercialMigrationActionRetryActivation CommercialMigrationAction = "retry_activation"
|
||||
CommercialMigrationActionUseV6Activation CommercialMigrationAction = "use_v6_activation_key"
|
||||
CommercialMigrationActionEnterSupportedV5 CommercialMigrationAction = "enter_supported_v5_key"
|
||||
CommercialMigrationActionFreeInstallationSlot CommercialMigrationAction = "free_installation_slot"
|
||||
CommercialMigrationActionRetrieveCurrentKey CommercialMigrationAction = "retrieve_current_key"
|
||||
CommercialMigrationActionAllowLicenseEgress CommercialMigrationAction = "allow_license_egress"
|
||||
)
|
||||
|
||||
const CommercialMigrationSustainedExchangeUnavailableSeconds int64 = 24 * 60 * 60
|
||||
|
||||
// CommercialMigrationStatus is the explicit v6-owned contract for unresolved
|
||||
// paid-license migrations entering from pre-v6 commercial state.
|
||||
type CommercialMigrationStatus struct {
|
||||
@@ -39,6 +46,7 @@ type CommercialMigrationStatus struct {
|
||||
State CommercialMigrationState `json:"state,omitempty"`
|
||||
Reason CommercialMigrationReason `json:"reason,omitempty"`
|
||||
RecommendedAction CommercialMigrationAction `json:"recommended_action,omitempty"`
|
||||
FirstFailedAt int64 `json:"first_failed_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *CommercialMigrationStatus) Active() bool {
|
||||
@@ -55,6 +63,9 @@ func NormalizeCommercialMigrationStatus(status *CommercialMigrationStatus) *Comm
|
||||
normalized.State = CommercialMigrationState(strings.TrimSpace(string(normalized.State)))
|
||||
normalized.Reason = CommercialMigrationReason(strings.TrimSpace(string(normalized.Reason)))
|
||||
normalized.RecommendedAction = CommercialMigrationAction(strings.TrimSpace(string(normalized.RecommendedAction)))
|
||||
if normalized.FirstFailedAt < 0 {
|
||||
normalized.FirstFailedAt = 0
|
||||
}
|
||||
|
||||
switch normalized.State {
|
||||
case CommercialMigrationStatePending, CommercialMigrationStateFailed:
|
||||
@@ -85,6 +96,47 @@ func CloneCommercialMigrationStatus(status *CommercialMigrationStatus) *Commerci
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func ApplyCommercialMigrationFailureTiming(status, previous *CommercialMigrationStatus, now time.Time) *CommercialMigrationStatus {
|
||||
normalized := NormalizeCommercialMigrationStatus(status)
|
||||
if normalized == nil {
|
||||
return nil
|
||||
}
|
||||
if !commercialMigrationTransportUnavailableReason(normalized.Reason) || normalized.State != CommercialMigrationStatePending {
|
||||
normalized.FirstFailedAt = 0
|
||||
return normalized
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
firstFailedAt := normalized.FirstFailedAt
|
||||
if prev := NormalizeCommercialMigrationStatus(previous); prev != nil &&
|
||||
prev.State == CommercialMigrationStatePending &&
|
||||
commercialMigrationTransportUnavailableReason(prev.Reason) &&
|
||||
prev.FirstFailedAt > 0 {
|
||||
firstFailedAt = prev.FirstFailedAt
|
||||
}
|
||||
if firstFailedAt <= 0 {
|
||||
firstFailedAt = now.Unix()
|
||||
}
|
||||
|
||||
normalized.FirstFailedAt = firstFailedAt
|
||||
if now.Unix()-firstFailedAt >= CommercialMigrationSustainedExchangeUnavailableSeconds {
|
||||
normalized.Reason = CommercialMigrationReasonExchangeConnectivity
|
||||
normalized.RecommendedAction = CommercialMigrationActionAllowLicenseEgress
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func commercialMigrationTransportUnavailableReason(reason CommercialMigrationReason) bool {
|
||||
switch reason {
|
||||
case CommercialMigrationReasonExchangeUnavailable, CommercialMigrationReasonExchangeConnectivity:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ClassifyLegacyExchangeError converts startup/manual exchange errors into a
|
||||
// retryable or terminal v6 migration contract.
|
||||
func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus {
|
||||
@@ -101,15 +153,22 @@ func ClassifyLegacyExchangeError(err error) *CommercialMigrationStatus {
|
||||
|
||||
var serverErr *LicenseServerError
|
||||
if errors.As(err, &serverErr) {
|
||||
serverCode := strings.ToUpper(strings.TrimSpace(serverErr.Code))
|
||||
switch serverErr.StatusCode {
|
||||
case 400:
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeMalformed
|
||||
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
|
||||
case 401:
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeInvalid
|
||||
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
|
||||
if serverCode == "RENEWED_KEY_AVAILABLE" {
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeStaleKey
|
||||
status.RecommendedAction = CommercialMigrationActionRetrieveCurrentKey
|
||||
} else {
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeInvalid
|
||||
status.RecommendedAction = CommercialMigrationActionEnterSupportedV5
|
||||
}
|
||||
case 403:
|
||||
status.State = CommercialMigrationStateFailed
|
||||
status.Reason = CommercialMigrationReasonExchangeRevoked
|
||||
|
||||
@@ -3,6 +3,7 @@ package licensing
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClassifyLegacyExchangeError(t *testing.T) {
|
||||
@@ -31,6 +32,13 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
|
||||
wantState: CommercialMigrationStateFailed,
|
||||
wantReason: CommercialMigrationReasonExchangeInvalid,
|
||||
},
|
||||
{
|
||||
name: "renewed key is terminal with retrieve guidance",
|
||||
err: fmt.Errorf("activation failed: %w", &LicenseServerError{StatusCode: 401, Code: "RENEWED_KEY_AVAILABLE"}),
|
||||
wantState: CommercialMigrationStateFailed,
|
||||
wantReason: CommercialMigrationReasonExchangeStaleKey,
|
||||
wantAction: CommercialMigrationActionRetrieveCurrentKey,
|
||||
},
|
||||
{
|
||||
name: "unsupported key format is terminal",
|
||||
err: fmt.Errorf("license key is not a supported v6 activation key or migratable v5 license"),
|
||||
@@ -70,3 +78,54 @@ func TestClassifyLegacyExchangeError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommercialMigrationFailureTimingEscalatesSustainedTransportFailure(t *testing.T) {
|
||||
start := time.Unix(1_700_000_000, 0)
|
||||
initial := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
|
||||
Source: CommercialMigrationSourceV5License,
|
||||
State: CommercialMigrationStatePending,
|
||||
Reason: CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: CommercialMigrationActionRetryActivation,
|
||||
}, nil, start)
|
||||
if initial == nil {
|
||||
t.Fatal("expected initial migration status")
|
||||
}
|
||||
if initial.FirstFailedAt != start.Unix() {
|
||||
t.Fatalf("first_failed_at=%d want %d", initial.FirstFailedAt, start.Unix())
|
||||
}
|
||||
if initial.Reason != CommercialMigrationReasonExchangeUnavailable {
|
||||
t.Fatalf("initial reason=%q want %q", initial.Reason, CommercialMigrationReasonExchangeUnavailable)
|
||||
}
|
||||
|
||||
escalated := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
|
||||
Source: CommercialMigrationSourceV5License,
|
||||
State: CommercialMigrationStatePending,
|
||||
Reason: CommercialMigrationReasonExchangeUnavailable,
|
||||
RecommendedAction: CommercialMigrationActionRetryActivation,
|
||||
}, initial, start.Add(time.Duration(CommercialMigrationSustainedExchangeUnavailableSeconds+1)*time.Second))
|
||||
if escalated == nil {
|
||||
t.Fatal("expected escalated migration status")
|
||||
}
|
||||
if escalated.FirstFailedAt != start.Unix() {
|
||||
t.Fatalf("escalated first_failed_at=%d want %d", escalated.FirstFailedAt, start.Unix())
|
||||
}
|
||||
if escalated.Reason != CommercialMigrationReasonExchangeConnectivity {
|
||||
t.Fatalf("escalated reason=%q want %q", escalated.Reason, CommercialMigrationReasonExchangeConnectivity)
|
||||
}
|
||||
if escalated.RecommendedAction != CommercialMigrationActionAllowLicenseEgress {
|
||||
t.Fatalf("escalated action=%q want %q", escalated.RecommendedAction, CommercialMigrationActionAllowLicenseEgress)
|
||||
}
|
||||
|
||||
terminal := ApplyCommercialMigrationFailureTiming(&CommercialMigrationStatus{
|
||||
Source: CommercialMigrationSourceV5License,
|
||||
State: CommercialMigrationStateFailed,
|
||||
Reason: CommercialMigrationReasonExchangeInvalid,
|
||||
RecommendedAction: CommercialMigrationActionEnterSupportedV5,
|
||||
}, escalated, start.Add(48*time.Hour))
|
||||
if terminal == nil {
|
||||
t.Fatal("expected terminal migration status")
|
||||
}
|
||||
if terminal.FirstFailedAt != 0 {
|
||||
t.Fatalf("terminal first_failed_at=%d want 0", terminal.FirstFailedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,22 +335,40 @@ func (c *LicenseServerClient) parseError(resp *http.Response) error {
|
||||
// Try to parse structured error response from the license server.
|
||||
if len(body) > 0 {
|
||||
var parsed struct {
|
||||
Code string `json:"code"`
|
||||
LegacyCode string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
Code string `json:"code"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
if json.Unmarshal(body, &parsed) == nil {
|
||||
code := strings.TrimSpace(parsed.Code)
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(parsed.LegacyCode)
|
||||
message := strings.TrimSpace(parsed.Message)
|
||||
retryable := parsed.Retryable
|
||||
if code == "" && len(parsed.Error) > 0 {
|
||||
var legacyCode string
|
||||
if json.Unmarshal(parsed.Error, &legacyCode) == nil {
|
||||
code = strings.TrimSpace(legacyCode)
|
||||
} else {
|
||||
var nested struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
if json.Unmarshal(parsed.Error, &nested) == nil {
|
||||
code = strings.TrimSpace(nested.Code)
|
||||
if strings.TrimSpace(nested.Message) != "" {
|
||||
message = strings.TrimSpace(nested.Message)
|
||||
}
|
||||
retryable = nested.Retryable
|
||||
}
|
||||
}
|
||||
}
|
||||
if code != "" {
|
||||
apiErr.Code = code
|
||||
if strings.TrimSpace(parsed.Message) != "" {
|
||||
apiErr.Message = parsed.Message
|
||||
if message != "" {
|
||||
apiErr.Message = message
|
||||
}
|
||||
apiErr.Retryable = parsed.Retryable
|
||||
apiErr.Retryable = retryable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,45 @@ func TestClientActivate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server returns nested v6 error envelope", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "RENEWED_KEY_AVAILABLE",
|
||||
"message": "Legacy license key has been superseded by a renewal",
|
||||
"retryable": false,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewLicenseServerClient(server.URL)
|
||||
_, err := client.ExchangeLegacyLicense(context.Background(), ExchangeLegacyLicenseRequest{
|
||||
LegacyLicenseKey: "header.payload.signature",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
apiErr, ok := err.(*LicenseServerError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *LicenseServerError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode)
|
||||
}
|
||||
if apiErr.Code != "RENEWED_KEY_AVAILABLE" {
|
||||
t.Errorf("Code = %q, want RENEWED_KEY_AVAILABLE", apiErr.Code)
|
||||
}
|
||||
if apiErr.Message != "Legacy license key has been superseded by a renewal" {
|
||||
t.Errorf("Message = %q, want nested message", apiErr.Message)
|
||||
}
|
||||
if apiErr.Retryable {
|
||||
t.Error("expected Retryable=false for renewed key")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("server returns legacy error field", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
@@ -2914,10 +2914,10 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
api_match["matched_contract_references"],
|
||||
[
|
||||
{
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 1179,
|
||||
"line": 1189,
|
||||
"heading_line": 140,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ type EntitlementPayload = {
|
||||
state?: string;
|
||||
reason?: string;
|
||||
recommended_action?: string;
|
||||
first_failed_at?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,6 +24,13 @@ type ExpectedCopy = {
|
||||
bodyFragments: RegExp[];
|
||||
};
|
||||
|
||||
type MigrationFixture = {
|
||||
state: string;
|
||||
reason: string;
|
||||
recommended_action: string;
|
||||
first_failed_at?: number;
|
||||
};
|
||||
|
||||
const expectedState = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE || '';
|
||||
const expectedReason = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_REASON || '';
|
||||
const expectedAction = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_ACTION || '';
|
||||
@@ -80,20 +88,28 @@ function expectFieldLocator(page: Page, label: string) {
|
||||
function expectedCopyFor(state: string, reason: string, action: string): ExpectedCopy {
|
||||
const actionFragment =
|
||||
action === 'retry_activation'
|
||||
? /retry activation from this instance/i
|
||||
? /retry from this instance/i
|
||||
: action === 'use_v6_activation_key'
|
||||
? /use the current v6 activation key for this purchase/i
|
||||
? /use the current v6 key for this purchase/i
|
||||
: action === 'enter_supported_v5_key'
|
||||
? /retry with the original v5 pro\/lifetime key from this instance/i
|
||||
: /review the activation state from this instance/i;
|
||||
: action === 'free_installation_slot'
|
||||
? /support@pulserelay\.pro/i
|
||||
: action === 'retrieve_current_key'
|
||||
? /pulserelay\.pro\/retrieve-license/i
|
||||
: action === 'allow_license_egress'
|
||||
? /allow outbound HTTPS to license\.pulserelay\.pro/i
|
||||
: /review the plan state from this instance/i;
|
||||
|
||||
if (state === 'pending') {
|
||||
const reasonFragment =
|
||||
reason === 'exchange_rate_limited'
|
||||
? /rate-limited right now/i
|
||||
: reason === 'exchange_conflict'
|
||||
? /another v6 activation handoff is still settling/i
|
||||
: /automatic v6 exchange did not complete yet/i;
|
||||
? /another v6 license handoff is still settling/i
|
||||
: reason === 'exchange_connectivity_required'
|
||||
? /paid v6 features require periodic outbound HTTPS/i
|
||||
: /automatic v6 exchange did not complete yet/i;
|
||||
|
||||
return {
|
||||
title: /v5 license migration pending/i,
|
||||
@@ -102,17 +118,21 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
|
||||
}
|
||||
|
||||
const reasonFragment =
|
||||
reason === 'exchange_invalid'
|
||||
? /key was rejected during v6 migration/i
|
||||
: reason === 'exchange_malformed'
|
||||
? /malformed and cannot be migrated/i
|
||||
: reason === 'exchange_revoked'
|
||||
? /no longer eligible for automatic migration/i
|
||||
: reason === 'exchange_non_migratable'
|
||||
? /not eligible for automatic v6 migration/i
|
||||
: reason === 'exchange_unsupported'
|
||||
? /not a supported v5 pro\/lifetime migration input/i
|
||||
: /could not be migrated automatically/i;
|
||||
reason === 'exchange_installation_limit'
|
||||
? /maximum number of v6 installations/i
|
||||
: reason === 'exchange_invalid'
|
||||
? /key was rejected during v6 migration/i
|
||||
: reason === 'exchange_stale_key'
|
||||
? /superseded by a renewal/i
|
||||
: reason === 'exchange_malformed'
|
||||
? /malformed and cannot be migrated/i
|
||||
: reason === 'exchange_revoked'
|
||||
? /no longer eligible for automatic migration/i
|
||||
: reason === 'exchange_non_migratable'
|
||||
? /not eligible for automatic v6 migration/i
|
||||
: reason === 'exchange_unsupported'
|
||||
? /not a supported v5 pro\/lifetime migration input/i
|
||||
: /could not be migrated automatically/i;
|
||||
|
||||
return {
|
||||
title: /v5 license migration needs attention/i,
|
||||
@@ -120,6 +140,78 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
|
||||
};
|
||||
}
|
||||
|
||||
function freeEntitlementsWithMigration(migration: MigrationFixture): EntitlementPayload {
|
||||
return {
|
||||
valid: false,
|
||||
tier: 'free',
|
||||
plan_version: 'community',
|
||||
subscription_state: 'expired',
|
||||
trial_eligible: false,
|
||||
trial_eligibility_reason: '',
|
||||
limits: [],
|
||||
commercial_migration: migration,
|
||||
};
|
||||
}
|
||||
|
||||
async function stubCommercialMigrationFixture(page: Page, migration: MigrationFixture) {
|
||||
const entitlements = freeEntitlementsWithMigration(migration);
|
||||
const commercialPosture = {
|
||||
...entitlements,
|
||||
upgrade_reasons: [],
|
||||
has_migration_gap: true,
|
||||
};
|
||||
|
||||
await page.route('**/api/license/runtime-capabilities', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
hosted_mode: false,
|
||||
max_history_days: 7,
|
||||
runtime: {
|
||||
build: 'community',
|
||||
label: 'Pulse Community runtime',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/license/entitlements', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(entitlements),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/license/commercial-posture', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(commercialPosture),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectMigrationNotice(page: Page, migration: MigrationFixture) {
|
||||
await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/settings/, { timeout: 10_000 });
|
||||
await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible();
|
||||
|
||||
const expectedCopy = expectedCopyFor(
|
||||
migration.state,
|
||||
migration.reason,
|
||||
migration.recommended_action,
|
||||
);
|
||||
await expect(page.getByText(expectedCopy.title)).toBeVisible();
|
||||
for (const fragment of expectedCopy.bodyFragments) {
|
||||
await expect(page.getByText(fragment)).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe.serial('v5 commercial migration notice', () => {
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(!expectedState, 'Set PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE to enable migration UI checks');
|
||||
@@ -175,11 +267,10 @@ test.describe.serial('v5 commercial migration notice', () => {
|
||||
|
||||
test('Pro settings renders the expected migration state', async ({ page }) => {
|
||||
await ensureAuthenticated(page);
|
||||
await page.goto('/settings/system-pro', { waitUntil: 'domcontentloaded' });
|
||||
await page.goto('/settings/pulse-intelligence/billing/plan', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForURL(/\/settings/, { timeout: 10_000 });
|
||||
|
||||
await expect(page.getByRole('heading', { name: /pro license/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /current license/i })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: /plans & billing/i }).first()).toBeVisible();
|
||||
|
||||
if (successMode) {
|
||||
await expect(page.getByText(/v5 license migration pending/i)).toHaveCount(0);
|
||||
@@ -211,3 +302,38 @@ test.describe.serial('v5 commercial migration notice', () => {
|
||||
await expect(page.getByRole('button', { name: /start 14-day pro trial/i })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('v5 commercial migration notice fixtures', () => {
|
||||
test('renders a stale renewed-key failure with retrieve-license guidance', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage');
|
||||
|
||||
const migration = {
|
||||
state: 'failed',
|
||||
reason: 'exchange_stale_key',
|
||||
recommended_action: 'retrieve_current_key',
|
||||
};
|
||||
await stubCommercialMigrationFixture(page, migration);
|
||||
await ensureAuthenticated(page);
|
||||
|
||||
await expectMigrationNotice(page, migration);
|
||||
});
|
||||
|
||||
test('renders a sustained license-server egress requirement without offering a trial', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only migration UI coverage');
|
||||
|
||||
const migration = {
|
||||
state: 'pending',
|
||||
reason: 'exchange_connectivity_required',
|
||||
recommended_action: 'allow_license_egress',
|
||||
first_failed_at: Math.floor(Date.now() / 1000) - 90_000,
|
||||
};
|
||||
await stubCommercialMigrationFixture(page, migration);
|
||||
await ensureAuthenticated(page);
|
||||
|
||||
await expectMigrationNotice(page, migration);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user