mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
Add paid feature claim proof bundle
This commit is contained in:
parent
17ba5f3043
commit
48c8d26198
14 changed files with 719 additions and 37 deletions
|
|
@ -0,0 +1,21 @@
|
|||
# Paid Feature Claim Proof Matrix
|
||||
|
||||
Date: 2026-04-29
|
||||
|
||||
## Decision
|
||||
|
||||
Self-hosted v6 paid claims are release-gated by an executable proof bundle. The bundle verifies that Community, Relay, and Pro claims line up across the canonical licensing contract, history entitlement enforcement, frontend plan copy, public checkout/pricing, download authorization, and Relay runtime entitlement behavior.
|
||||
|
||||
## Proof Command
|
||||
|
||||
```bash
|
||||
python3 scripts/release_control/paid_feature_claims_proof.py --json
|
||||
```
|
||||
|
||||
## Required Coverage
|
||||
|
||||
- Community keeps core monitoring free and does not introduce monitored-system volume caps.
|
||||
- Relay claims only remote web access, mobile pairing, push notifications, and 14-day metric history.
|
||||
- Pro preserves Relay capabilities and adds operator extras: root-cause analysis, safe remediation workflows, 90-day metric history, RBAC, audit logging, reporting, SAML SSO, and agent profiles.
|
||||
- History claims are enforced by the runtime metrics-history API, not only shown in copy.
|
||||
- Public pricing, checkout, download, and relay-server entitlement behavior remain consistent with those claims.
|
||||
|
|
@ -2512,6 +2512,12 @@
|
|||
"path": "docs/release-control/v6/internal/records/cloud-msp-price-audit-2026-03-13.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-2026-04-20.md",
|
||||
|
|
@ -2527,6 +2533,12 @@
|
|||
"path": "docs/release-control/v6/internal/subsystems/cloud-paid.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "scripts/release_control/paid_feature_claims_proof.py",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse-pro",
|
||||
"path": "V6_LAUNCH_CHECKLIST.md",
|
||||
|
|
@ -4415,11 +4427,23 @@
|
|||
"L16"
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/paid-feature-entitlement-gating-2026-03-12.md",
|
||||
"kind": "file",
|
||||
"evidence_tier": "managed-runtime-exercise"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "scripts/release_control/paid_feature_claims_proof.py",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,19 +178,27 @@ The canonical operator preview for that cutover now lives in
|
|||
`pulse-pro/scripts/grandfathered_recurring_cutover_preview.py`, and it must use
|
||||
the same active-at-snapshot rule as the live server before anyone sets
|
||||
`PULSE_LICENSE_GRANDFATHERED_RECURRING_SNAPSHOT_AT`.
|
||||
That same commercial control boundary also owns the server-side checkout funnel
|
||||
analytics, alerting, digest generation, and admin summary payloads in
|
||||
`pulse-pro:license-server/checkout_funnel.go`. Alert evaluation there must use
|
||||
rolling trailing windows for "last 24 hours" and "previous 7 days" semantics
|
||||
rather than UTC calendar-day buckets, so midnight does not suppress active
|
||||
checkout regressions or leave the admin summary without the alert state the
|
||||
server already observed.
|
||||
The retired server-side checkout funnel analytics, alerting, digest
|
||||
generation, and admin summary payloads are no longer part of the active v6
|
||||
proof floor. `pulse-pro:license-server/v6_checkout.go` may accept legacy
|
||||
funnel-telemetry fields only as ignored compatibility input for older static
|
||||
site deploys; active paid-feature proof must stay on checkout creation,
|
||||
portal-handoff state, pricing/catalog truth, download delivery, and license or
|
||||
relay entitlement behavior rather than requiring the removed funnel monitoring
|
||||
files.
|
||||
That same public-pricing boundary also owns the machine-readable v6 pricing
|
||||
validator and launch checklist in `pulse-pro:scripts/validate_public_pricing_model.py`
|
||||
and `pulse-pro:V6_LAUNCH_CHECKLIST.md`. Those operator surfaces must describe
|
||||
self-hosted Pulse as core monitoring included by default, must not reintroduce
|
||||
monitored-system upsell language, and must verify the no-cap release posture
|
||||
rather than legacy Community limit enforcement.
|
||||
Self-hosted paid-feature claims must also remain covered by the executable
|
||||
cross-repo proof bundle in `scripts/release_control/paid_feature_claims_proof.py`.
|
||||
When Community, Relay, or Pro claims change, that proof must continue to verify
|
||||
the canonical licensing matrix, runtime history entitlement enforcement,
|
||||
frontend plan presentation, public pricing/checkout/download behavior, and
|
||||
Relay entitlement acceptance together rather than treating copy, checkout, and
|
||||
runtime gating as separate unlinked claims.
|
||||
|
||||
## Extension Points
|
||||
|
||||
|
|
|
|||
|
|
@ -1834,7 +1834,6 @@
|
|||
"internal/hosted/provisioner.go",
|
||||
"pulse-pro:.github/workflows/deploy-v6-preview-demo.yml",
|
||||
"pulse-pro:landing-page/thanks.html",
|
||||
"pulse-pro:license-server/checkout_funnel.go",
|
||||
"pulse-pro:license-server/grandfathering.go",
|
||||
"pulse-pro:license-server/main.go",
|
||||
"pulse-pro:license-server/self_hosted_feature_catalog.generated.go",
|
||||
|
|
@ -2530,19 +2529,6 @@
|
|||
"frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "checkout-funnel-analytics",
|
||||
"label": "checkout funnel analytics proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pulse-pro:license-server/checkout_funnel.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pulse-pro:license-server/checkout_funnel_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "legacy-grandfathering-cutover",
|
||||
"label": "legacy recurring grandfathering cutover proof",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
SELF_HOSTED_RECOVERY_PRESENTATION,
|
||||
} from '@/utils/licensePresentation';
|
||||
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from '@/components/Settings/selfHostedBillingPresentation';
|
||||
import { getSelfHostedPlanEntitlementSummary } from '@/utils/selfHostedPlans';
|
||||
|
||||
describe('licensePresentation', () => {
|
||||
it('returns canonical tier labels', () => {
|
||||
|
|
@ -378,6 +379,38 @@ describe('licensePresentation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('sources active self-hosted current-plan summaries from the shared plan contract', () => {
|
||||
expect(
|
||||
getSelfHostedCurrentPlanPresentation({
|
||||
entitlements: {
|
||||
tier: 'relay',
|
||||
subscription_state: 'active',
|
||||
capabilities: ['relay', 'mobile_app', 'push_notifications'],
|
||||
limits: [],
|
||||
upgrade_reasons: [],
|
||||
},
|
||||
displayableCapabilities: [
|
||||
'Pulse Relay (Remote Access)',
|
||||
'Mobile App Pairing',
|
||||
'Push Notifications',
|
||||
],
|
||||
}).body,
|
||||
).toBe(getSelfHostedPlanEntitlementSummary('relay', 'Relay'));
|
||||
|
||||
expect(
|
||||
getSelfHostedCurrentPlanPresentation({
|
||||
entitlements: {
|
||||
tier: 'pro_plus',
|
||||
subscription_state: 'active',
|
||||
capabilities: ['relay', 'ai_autofix'],
|
||||
limits: [],
|
||||
upgrade_reasons: [],
|
||||
},
|
||||
displayableCapabilities: ['Pulse Relay (Remote Access)', 'Safe Remediation Workflows'],
|
||||
}).body,
|
||||
).toBe(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+'));
|
||||
});
|
||||
|
||||
it('builds restrained higher-tier comparison cards for Community and Relay only', () => {
|
||||
expect(
|
||||
getSelfHostedPlanComparisonPresentation({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getSelfHostedPlanEntitlementSummary,
|
||||
getSelfHostedPlanDefinitionForBillingTier,
|
||||
SELF_HOSTED_COMMERCIAL_PRESENTATION,
|
||||
SELF_HOSTED_FEATURE_ROWS,
|
||||
SELF_HOSTED_PLAN_BY_TIER,
|
||||
SELF_HOSTED_PLAN_DEFINITIONS,
|
||||
} from '../selfHostedPlans';
|
||||
import {
|
||||
getSelfHostedFeaturesForRole,
|
||||
SELF_HOSTED_FEATURE_CATALOG,
|
||||
} from '../selfHostedFeatureCatalog.generated';
|
||||
|
||||
const primaryPillarClaimLabels = (tier: 'relay' | 'pro', metricHistoryDays: number) =>
|
||||
getSelfHostedFeaturesForRole(tier, 'primary_pillar').map((entry) =>
|
||||
entry.key === 'long_term_metrics'
|
||||
? `${metricHistoryDays}-day metric history`
|
||||
: entry.comparisonName,
|
||||
);
|
||||
|
||||
describe('selfHostedPlans', () => {
|
||||
it('keeps core monitoring aligned as an included self-hosted baseline', () => {
|
||||
|
|
@ -145,6 +157,96 @@ describe('selfHostedPlans', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('derives paid self-hosted feature claims from the generated catalog', () => {
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.community.entitlementSummary).toBe(
|
||||
getSelfHostedPlanEntitlementSummary('community'),
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary).toBe(
|
||||
getSelfHostedPlanEntitlementSummary('relay'),
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary).toBe(
|
||||
getSelfHostedPlanEntitlementSummary('pro'),
|
||||
);
|
||||
expect(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+')).toBe(
|
||||
'Legacy Pulse Pro+ is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.',
|
||||
);
|
||||
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual(
|
||||
primaryPillarClaimLabels('relay', SELF_HOSTED_PLAN_BY_TIER.relay.metricHistoryDays),
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights).toEqual(
|
||||
primaryPillarClaimLabels('pro', SELF_HOSTED_PLAN_BY_TIER.pro.metricHistoryDays),
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.includedExtras).toEqual(
|
||||
getSelfHostedFeaturesForRole('pro', 'included_extra').map((entry) => entry.displayName),
|
||||
);
|
||||
|
||||
const displayablePaidFeatures = SELF_HOSTED_FEATURE_CATALOG.filter(
|
||||
(entry) =>
|
||||
entry.displayableInSelfHostedPlan &&
|
||||
!entry.includedIn.community &&
|
||||
(entry.includedIn.relay || entry.includedIn.pro),
|
||||
).map((entry) => entry.key);
|
||||
expect(displayablePaidFeatures).toEqual([
|
||||
'relay',
|
||||
'mobile_app',
|
||||
'push_notifications',
|
||||
'ai_alerts',
|
||||
'ai_autofix',
|
||||
'long_term_metrics',
|
||||
'advanced_sso',
|
||||
'rbac',
|
||||
'audit_logging',
|
||||
'advanced_reporting',
|
||||
'agent_profiles',
|
||||
]);
|
||||
expect(
|
||||
SELF_HOSTED_PLAN_DEFINITIONS.map((plan) => ({
|
||||
tier: plan.tier,
|
||||
historyDays: plan.metricHistoryDays,
|
||||
extras: plan.billingExtrasSummary,
|
||||
})),
|
||||
).toEqual([
|
||||
{ tier: 'community', historyDays: 7, extras: 'Patrol, alerts, and OIDC' },
|
||||
{
|
||||
tier: 'relay',
|
||||
historyDays: 14,
|
||||
extras: 'Remote web access, mobile pairing, and push',
|
||||
},
|
||||
{ tier: 'pro', historyDays: 90, extras: 'Analysis, remediation, and admin controls' },
|
||||
]);
|
||||
expect(SELF_HOSTED_FEATURE_ROWS.find((row) => row.key === 'history')).toEqual({
|
||||
key: 'history',
|
||||
name: 'Metric History',
|
||||
community: '7 days',
|
||||
relay: '14 days',
|
||||
pro: '90 days',
|
||||
});
|
||||
|
||||
const paidClaimText = [
|
||||
SELF_HOSTED_COMMERCIAL_PRESENTATION.pageDescription,
|
||||
SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary,
|
||||
SELF_HOSTED_PLAN_BY_TIER.relay.comparisonSummary,
|
||||
...SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights,
|
||||
...SELF_HOSTED_PLAN_BY_TIER.relay.highlights,
|
||||
SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary,
|
||||
SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary,
|
||||
...SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights,
|
||||
...SELF_HOSTED_PLAN_BY_TIER.pro.includedExtras,
|
||||
...SELF_HOSTED_PLAN_BY_TIER.pro.highlights,
|
||||
].join('\n');
|
||||
|
||||
for (const hiddenEntry of SELF_HOSTED_FEATURE_CATALOG.filter(
|
||||
(entry) => !entry.displayableInSelfHostedPlan,
|
||||
)) {
|
||||
expect(paidClaimText).not.toContain(hiddenEntry.displayName);
|
||||
expect(paidClaimText).not.toContain(hiddenEntry.comparisonName);
|
||||
}
|
||||
expect(paidClaimText).not.toMatch(
|
||||
/unlimited|monitoring capacity|guest capacity|hosted quickstart|trial/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps current billing tiers back to the shared self-hosted plan definitions', () => {
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('free')).toBe(
|
||||
SELF_HOSTED_PLAN_BY_TIER.community,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import {
|
|||
type MonitoredSystemCapacityStatus,
|
||||
type MonitoredSystemLimitUsageStatus,
|
||||
} from '@/utils/monitoredSystemPresentation';
|
||||
import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans';
|
||||
import {
|
||||
getSelfHostedPlanDefinitionForBillingTier,
|
||||
getSelfHostedPlanEntitlementSummary,
|
||||
} from '@/utils/selfHostedPlans';
|
||||
import {
|
||||
getSelfHostedFeatureCatalogEntry,
|
||||
isDisplayableSelfHostedFeatureKey,
|
||||
|
|
@ -417,16 +420,10 @@ const getSelfHostedActivePlanSummary = (
|
|||
planLabel: string,
|
||||
planDefinition: ReturnType<typeof getSelfHostedPlanDefinitionForBillingTier>,
|
||||
): string | null => {
|
||||
switch (planDefinition?.tier) {
|
||||
case 'community':
|
||||
return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.`;
|
||||
case 'relay':
|
||||
return `${planLabel} is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.`;
|
||||
case 'pro':
|
||||
return `${planLabel} is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.`;
|
||||
default:
|
||||
return null;
|
||||
if (!planDefinition) {
|
||||
return null;
|
||||
}
|
||||
return getSelfHostedPlanEntitlementSummary(planDefinition.tier, planLabel);
|
||||
};
|
||||
|
||||
export const getSelfHostedPlanComparisonPresentation = ({
|
||||
|
|
|
|||
|
|
@ -68,6 +68,26 @@ const getTierEntitlementHighlights = (
|
|||
const getTierIncludedExtras = (tier: SelfHostedTierKey): readonly string[] =>
|
||||
getSelfHostedFeaturesForRole(tier, 'included_extra').map((entry) => entry.displayName);
|
||||
|
||||
const SELF_HOSTED_PLAN_DEFAULT_ENTITLEMENT_LABELS: Record<SelfHostedTierKey, string> = {
|
||||
community: 'Community',
|
||||
relay: 'Relay',
|
||||
pro: 'Pulse Pro',
|
||||
};
|
||||
|
||||
export function getSelfHostedPlanEntitlementSummary(
|
||||
tier: SelfHostedTierKey,
|
||||
planLabel = SELF_HOSTED_PLAN_DEFAULT_ENTITLEMENT_LABELS[tier],
|
||||
): string {
|
||||
switch (tier) {
|
||||
case 'community':
|
||||
return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.`;
|
||||
case 'relay':
|
||||
return `${planLabel} is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.`;
|
||||
case 'pro':
|
||||
return `${planLabel} is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.`;
|
||||
}
|
||||
}
|
||||
|
||||
const buildSelfHostedComparisonFeatureRows = (): readonly SelfHostedFeatureRow[] =>
|
||||
SELF_HOSTED_FEATURE_CATALOG.filter((entry) => entry.showInComparisonTable).map((entry) => ({
|
||||
key: entry.key,
|
||||
|
|
@ -85,8 +105,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
subline: 'Core monitoring included',
|
||||
metricHistoryDays: 7,
|
||||
billingExtrasSummary: 'Patrol, alerts, and OIDC',
|
||||
entitlementSummary:
|
||||
'Community is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.',
|
||||
entitlementSummary: getSelfHostedPlanEntitlementSummary('community'),
|
||||
entitlementHighlights: [
|
||||
'Real-time monitoring',
|
||||
'7-day metric history',
|
||||
|
|
@ -112,8 +131,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
subline: 'or $4.99/month',
|
||||
metricHistoryDays: 14,
|
||||
billingExtrasSummary: 'Remote web access, mobile pairing, and push',
|
||||
entitlementSummary:
|
||||
'Relay is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.',
|
||||
entitlementSummary: getSelfHostedPlanEntitlementSummary('relay'),
|
||||
entitlementHighlights: getTierEntitlementHighlights('relay', 14),
|
||||
includedExtras: [],
|
||||
comparisonSummary:
|
||||
|
|
@ -134,8 +152,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
subline: 'or $8.99/month',
|
||||
metricHistoryDays: 90,
|
||||
billingExtrasSummary: 'Analysis, remediation, and admin controls',
|
||||
entitlementSummary:
|
||||
'Pulse Pro is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.',
|
||||
entitlementSummary: getSelfHostedPlanEntitlementSummary('pro'),
|
||||
entitlementHighlights: getTierEntitlementHighlights('pro', 90),
|
||||
includedExtras: getTierIncludedExtras('pro'),
|
||||
comparisonSummary:
|
||||
|
|
|
|||
|
|
@ -179,10 +179,13 @@ func TestHandleMetricsHistory_TierAwareHistoryRanges(t *testing.T) {
|
|||
range_ string
|
||||
want int
|
||||
}{
|
||||
{name: "community allows free seven day history", range_: "7d", want: http.StatusOK},
|
||||
{name: "community blocks relay history", range_: "14d", want: http.StatusPaymentRequired},
|
||||
{name: "relay allows 14 days", tier: pkglicensing.TierRelay, range_: "14d", want: http.StatusOK},
|
||||
{name: "relay allows equivalent hour range", tier: pkglicensing.TierRelay, range_: "336h", want: http.StatusOK},
|
||||
{name: "relay blocks longer day range", tier: pkglicensing.TierRelay, range_: "15d", want: http.StatusPaymentRequired},
|
||||
{name: "pro allows 90 days", tier: pkglicensing.TierPro, range_: "90d", want: http.StatusOK},
|
||||
{name: "pro blocks longer than 90 days", tier: pkglicensing.TierPro, range_: "91d", want: http.StatusPaymentRequired},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,129 @@ func TestDeriveCapabilitiesFromTier_Sorted(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tier Tier
|
||||
wantHistoryDays int
|
||||
wantCoreMonitoringLimit int
|
||||
wantIncludedCapabilities []string
|
||||
wantExcludedCapabilities []string
|
||||
}{
|
||||
{
|
||||
name: "community keeps core monitoring free and does not claim paid extras",
|
||||
tier: TierFree,
|
||||
wantHistoryDays: 7,
|
||||
wantCoreMonitoringLimit: 0,
|
||||
wantIncludedCapabilities: []string{
|
||||
FeatureUpdateAlerts,
|
||||
FeatureSSO,
|
||||
FeatureAIPatrol,
|
||||
},
|
||||
wantExcludedCapabilities: []string{
|
||||
FeatureRelay,
|
||||
FeatureMobileApp,
|
||||
FeaturePushNotifications,
|
||||
FeatureLongTermMetrics,
|
||||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureRBAC,
|
||||
FeatureAdvancedReporting,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "relay sells remote access mobile handoff push and fourteen day history",
|
||||
tier: TierRelay,
|
||||
wantHistoryDays: 14,
|
||||
wantCoreMonitoringLimit: 0,
|
||||
wantIncludedCapabilities: []string{
|
||||
FeatureUpdateAlerts,
|
||||
FeatureSSO,
|
||||
FeatureAIPatrol,
|
||||
FeatureRelay,
|
||||
FeatureMobileApp,
|
||||
FeaturePushNotifications,
|
||||
FeatureLongTermMetrics,
|
||||
},
|
||||
wantExcludedCapabilities: []string{
|
||||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureRBAC,
|
||||
FeatureAdvancedReporting,
|
||||
FeatureAgentProfiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pro sells operator extras and preserves relay capabilities",
|
||||
tier: TierPro,
|
||||
wantHistoryDays: 90,
|
||||
wantCoreMonitoringLimit: 0,
|
||||
wantIncludedCapabilities: []string{
|
||||
FeatureRelay,
|
||||
FeatureMobileApp,
|
||||
FeaturePushNotifications,
|
||||
FeatureLongTermMetrics,
|
||||
FeatureAIAlerts,
|
||||
FeatureAIAutoFix,
|
||||
FeatureRBAC,
|
||||
FeatureAuditLogging,
|
||||
FeatureAdvancedReporting,
|
||||
FeatureAgentProfiles,
|
||||
},
|
||||
wantExcludedCapabilities: []string{
|
||||
FeatureMultiTenant,
|
||||
FeatureUnlimited,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotHistoryDays, ok := TierHistoryDays[tt.tier]
|
||||
if !ok {
|
||||
t.Fatalf("TierHistoryDays missing entry for tier %q", tt.tier)
|
||||
}
|
||||
if gotHistoryDays != tt.wantHistoryDays {
|
||||
t.Fatalf(
|
||||
"TierHistoryDays[%q] = %d, want %d",
|
||||
tt.tier,
|
||||
gotHistoryDays,
|
||||
tt.wantHistoryDays,
|
||||
)
|
||||
}
|
||||
|
||||
gotMonitoringLimit, ok := TierMonitoredSystemLimits[tt.tier]
|
||||
if !ok {
|
||||
t.Fatalf("TierMonitoredSystemLimits missing entry for tier %q", tt.tier)
|
||||
}
|
||||
if gotMonitoringLimit != tt.wantCoreMonitoringLimit {
|
||||
t.Fatalf(
|
||||
"TierMonitoredSystemLimits[%q] = %d, want %d",
|
||||
tt.tier,
|
||||
gotMonitoringLimit,
|
||||
tt.wantCoreMonitoringLimit,
|
||||
)
|
||||
}
|
||||
|
||||
capabilities := make(map[string]struct{})
|
||||
for _, capability := range TierFeatures[tt.tier] {
|
||||
capabilities[capability] = struct{}{}
|
||||
}
|
||||
for _, capability := range tt.wantIncludedCapabilities {
|
||||
if _, ok := capabilities[capability]; !ok {
|
||||
t.Fatalf("tier %q missing paid-feature claim capability %q", tt.tier, capability)
|
||||
}
|
||||
}
|
||||
for _, capability := range tt.wantExcludedCapabilities {
|
||||
if _, ok := capabilities[capability]; ok {
|
||||
t.Fatalf("tier %q unexpectedly includes capability %q", tt.tier, capability)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveEntitlements(t *testing.T) {
|
||||
caps, limits := DeriveEntitlements(TierPro, []string{"custom"}, 50, 100)
|
||||
|
||||
|
|
|
|||
214
scripts/release_control/internal/paid_feature_claims_proof.py
Normal file
214
scripts/release_control/internal/paid_feature_claims_proof.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run the automated proof bundle for self-hosted paid feature claims."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandSpec:
|
||||
name: str
|
||||
cwd: str
|
||||
command: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
name: str
|
||||
cwd: str
|
||||
command: list[str]
|
||||
ok: bool
|
||||
exit_code: int
|
||||
detail: str
|
||||
|
||||
|
||||
def default_pulse_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def default_pulse_pro_license_server_dir() -> Path:
|
||||
return default_pulse_dir().parent / "pulse-pro" / "license-server"
|
||||
|
||||
|
||||
def default_pulse_pro_relay_server_dir() -> Path:
|
||||
return default_pulse_dir().parent / "pulse-pro" / "relay-server"
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the automated proof bundle for v6 paid feature claims."
|
||||
)
|
||||
parser.add_argument("--pulse-dir", default=str(default_pulse_dir()))
|
||||
parser.add_argument("--frontend-dir", help="Optional override for the pulse frontend-modern directory")
|
||||
parser.add_argument(
|
||||
"--pulse-pro-license-server-dir",
|
||||
default=str(default_pulse_pro_license_server_dir()),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pulse-pro-relay-server-dir",
|
||||
default=str(default_pulse_pro_relay_server_dir()),
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
|
||||
parser.add_argument("--report-out", help="Optional path to write a markdown report")
|
||||
parser.add_argument(
|
||||
"--report-title",
|
||||
default="Paid Feature Claim Automated Proof",
|
||||
help="Markdown title used when writing --report-out",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def frontend_dir_from_args(args: argparse.Namespace) -> Path:
|
||||
if args.frontend_dir:
|
||||
return Path(args.frontend_dir).resolve()
|
||||
return Path(args.pulse_dir).resolve() / "frontend-modern"
|
||||
|
||||
|
||||
def build_command_specs(args: argparse.Namespace) -> list[CommandSpec]:
|
||||
pulse_dir = Path(args.pulse_dir).resolve()
|
||||
frontend_dir = frontend_dir_from_args(args)
|
||||
pulse_pro_license_server_dir = Path(args.pulse_pro_license_server_dir).resolve()
|
||||
pulse_pro_relay_server_dir = Path(args.pulse_pro_relay_server_dir).resolve()
|
||||
return [
|
||||
CommandSpec(
|
||||
name="pulse-licensing-paid-feature-contract",
|
||||
cwd=str(pulse_dir),
|
||||
command=[
|
||||
"go",
|
||||
"test",
|
||||
"./pkg/licensing",
|
||||
"-run",
|
||||
"^(TestSelfHostedPaidFeatureClaimMatrix|TestTierMonitoredSystemLimits|TestTierHistoryDays|TestTierFeatureInheritance|TestBuildEntitlementPayload_MaxHistoryDays)$",
|
||||
"-count=1",
|
||||
],
|
||||
),
|
||||
CommandSpec(
|
||||
name="pulse-api-history-entitlement-enforcement",
|
||||
cwd=str(pulse_dir),
|
||||
command=[
|
||||
"go",
|
||||
"test",
|
||||
"./internal/api",
|
||||
"-run",
|
||||
"^TestHandleMetricsHistory_TierAwareHistoryRanges$",
|
||||
"-count=1",
|
||||
],
|
||||
),
|
||||
CommandSpec(
|
||||
name="pulse-frontend-paid-claim-contract",
|
||||
cwd=str(frontend_dir),
|
||||
command=[
|
||||
"npm",
|
||||
"test",
|
||||
"--",
|
||||
"src/utils/__tests__/selfHostedPlans.test.ts",
|
||||
"src/utils/__tests__/commercialBillingModel.test.ts",
|
||||
"src/utils/__tests__/licensePresentation.test.ts",
|
||||
"src/stores/__tests__/license.test.ts",
|
||||
"src/components/shared/__tests__/HistoryChart.test.tsx",
|
||||
"src/components/Settings/__tests__/ProLicensePanel.test.tsx",
|
||||
"src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx",
|
||||
],
|
||||
),
|
||||
CommandSpec(
|
||||
name="pulse-pro-public-pricing-and-checkout-contract",
|
||||
cwd=str(pulse_pro_license_server_dir),
|
||||
command=[
|
||||
"go",
|
||||
"test",
|
||||
".",
|
||||
"-run",
|
||||
"^(TestHandlePublicPricingModelV6|TestCanonicalTierEntitlementContracts_DoNotMonetizeMonitoringVolume|TestV6PublicProPricingPreservesPaidCapabilityFloor|TestHandleCheckoutSessionCreate|TestResolvePublicCheckoutPlan_LockedSelfHostedTiers|TestHandlePulseProDownloadRequiresAuth|TestHandlePulseProDownloadActivationKeyReturnsSignedURLs|TestHandlePulseProDownloadRejectsFreeLicense)$",
|
||||
"-count=1",
|
||||
],
|
||||
),
|
||||
CommandSpec(
|
||||
name="pulse-pro-relay-entitlement-runtime",
|
||||
cwd=str(pulse_pro_relay_server_dir),
|
||||
command=[
|
||||
"go",
|
||||
"test",
|
||||
".",
|
||||
"-run",
|
||||
"^(TestValidateLicense_AllProTiers|TestValidateLicense_FreeTierRejected|TestHostedEntitlementValidator_RejectsMissingRelayCapability|TestBridge_V6GrantRegister|TestBridge_V6GrantRejected_CancelledState|TestBridge_V6Grant_FullDataRouting|TestBridge_PushNotificationHandled)$",
|
||||
"-count=1",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def summarize_output(stdout: str, stderr: str) -> str:
|
||||
stdout_lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
||||
stderr_lines = [line.strip() for line in stderr.splitlines() if line.strip()]
|
||||
for line in reversed(stdout_lines):
|
||||
if line.startswith("ok ") or line.startswith("PASS ") or " passed " in line:
|
||||
return line[:237] + "..." if len(line) > 240 else line
|
||||
lines = stdout_lines or stderr_lines
|
||||
if not lines:
|
||||
return "pass"
|
||||
summary = lines[-1]
|
||||
if len(summary) > 240:
|
||||
return summary[:237] + "..."
|
||||
return summary
|
||||
|
||||
|
||||
def run_command(spec: CommandSpec) -> CommandResult:
|
||||
proc = subprocess.run(spec.command, cwd=spec.cwd, capture_output=True, text=True, check=False)
|
||||
return CommandResult(
|
||||
name=spec.name,
|
||||
cwd=spec.cwd,
|
||||
command=spec.command,
|
||||
ok=proc.returncode == 0,
|
||||
exit_code=proc.returncode,
|
||||
detail=summarize_output(proc.stdout, proc.stderr),
|
||||
)
|
||||
|
||||
|
||||
def run_proof(args: argparse.Namespace) -> list[CommandResult]:
|
||||
return [run_command(spec) for spec in build_command_specs(args)]
|
||||
|
||||
|
||||
def render_markdown_report(title: str, results: list[CommandResult]) -> str:
|
||||
lines = [
|
||||
f"# {title}",
|
||||
"",
|
||||
"## Scope",
|
||||
"",
|
||||
"This proof checks that self-hosted paid claims map to concrete entitlement, API, UI, license-server, and relay-server behavior.",
|
||||
"",
|
||||
"## Results",
|
||||
"",
|
||||
]
|
||||
for result in results:
|
||||
status = "PASS" if result.ok else "FAIL"
|
||||
lines.append(f"- `{status}` `{result.name}`")
|
||||
lines.append(f" - cwd: `{result.cwd}`")
|
||||
lines.append(f" - command: `{' '.join(result.command)}`")
|
||||
lines.append(f" - detail: {result.detail}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
results = run_proof(args)
|
||||
if args.report_out:
|
||||
Path(args.report_out).write_text(
|
||||
render_markdown_report(args.report_title, results),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps([asdict(result) for result in results], indent=2))
|
||||
else:
|
||||
for result in results:
|
||||
status = "PASS" if result.ok else "FAIL"
|
||||
print(f"{status} {result.name}: {result.detail}")
|
||||
return 0 if all(result.ok for result in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
INTERNAL_DIR = Path(__file__).resolve().parent
|
||||
if str(INTERNAL_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(INTERNAL_DIR))
|
||||
|
||||
import paid_feature_claims_proof as proof
|
||||
|
||||
|
||||
def run_main(argv: list[str]) -> tuple[int, str]:
|
||||
buffer = io.StringIO()
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
exit_code = proof.main(argv)
|
||||
return exit_code, buffer.getvalue()
|
||||
|
||||
|
||||
class PaidFeatureClaimsProofTest(unittest.TestCase):
|
||||
def test_default_paths_resolve_from_internal_script_location(self) -> None:
|
||||
self.assertEqual(proof.default_pulse_dir(), Path(proof.__file__).resolve().parents[3])
|
||||
self.assertEqual(
|
||||
proof.default_pulse_pro_license_server_dir(),
|
||||
proof.default_pulse_dir().parent / "pulse-pro" / "license-server",
|
||||
)
|
||||
self.assertEqual(
|
||||
proof.default_pulse_pro_relay_server_dir(),
|
||||
proof.default_pulse_dir().parent / "pulse-pro" / "relay-server",
|
||||
)
|
||||
|
||||
def test_build_command_specs_cover_paid_claim_layers(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
pulse_dir = Path(tmp) / "pulse"
|
||||
pulse_dir.mkdir()
|
||||
frontend_dir = pulse_dir / "frontend-modern"
|
||||
frontend_dir.mkdir()
|
||||
pulse_pro_license_server_dir = Path(tmp) / "pulse-pro" / "license-server"
|
||||
pulse_pro_license_server_dir.mkdir(parents=True)
|
||||
pulse_pro_relay_server_dir = Path(tmp) / "pulse-pro" / "relay-server"
|
||||
pulse_pro_relay_server_dir.mkdir(parents=True)
|
||||
|
||||
args = proof.parse_args(
|
||||
[
|
||||
"--pulse-dir",
|
||||
str(pulse_dir),
|
||||
"--pulse-pro-license-server-dir",
|
||||
str(pulse_pro_license_server_dir),
|
||||
"--pulse-pro-relay-server-dir",
|
||||
str(pulse_pro_relay_server_dir),
|
||||
]
|
||||
)
|
||||
specs = proof.build_command_specs(args)
|
||||
|
||||
self.assertEqual(len(specs), 5)
|
||||
self.assertEqual(specs[0].cwd, str(pulse_dir.resolve()))
|
||||
self.assertEqual(specs[2].cwd, str(frontend_dir.resolve()))
|
||||
self.assertEqual(specs[3].cwd, str(pulse_pro_license_server_dir.resolve()))
|
||||
self.assertEqual(specs[4].cwd, str(pulse_pro_relay_server_dir.resolve()))
|
||||
names = {spec.name for spec in specs}
|
||||
self.assertIn("pulse-licensing-paid-feature-contract", names)
|
||||
self.assertIn("pulse-api-history-entitlement-enforcement", names)
|
||||
self.assertIn("pulse-frontend-paid-claim-contract", names)
|
||||
self.assertIn("pulse-pro-public-pricing-and-checkout-contract", names)
|
||||
self.assertIn("pulse-pro-relay-entitlement-runtime", names)
|
||||
|
||||
def test_run_command_success_and_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
ok = proof.run_command(
|
||||
proof.CommandSpec(
|
||||
name="ok",
|
||||
cwd=tmp,
|
||||
command=["python3", "-c", "print('paid proof ok')"],
|
||||
)
|
||||
)
|
||||
self.assertTrue(ok.ok)
|
||||
self.assertEqual(ok.detail, "paid proof ok")
|
||||
|
||||
failed = proof.run_command(
|
||||
proof.CommandSpec(
|
||||
name="fail",
|
||||
cwd=tmp,
|
||||
command=["python3", "-c", "import sys; sys.stderr.write('boom\\n'); sys.exit(3)"],
|
||||
)
|
||||
)
|
||||
self.assertFalse(failed.ok)
|
||||
self.assertEqual(failed.exit_code, 3)
|
||||
self.assertEqual(failed.detail, "boom")
|
||||
|
||||
def test_summarize_output_prefers_success_summary_over_expected_error_logs(self) -> None:
|
||||
stdout = """
|
||||
stderr | expected store fallback test
|
||||
Error: API error
|
||||
|
||||
Test Files 7 passed (7)
|
||||
Tests 116 passed (116)
|
||||
"""
|
||||
stderr = """
|
||||
at runSuite (node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8)
|
||||
"""
|
||||
|
||||
self.assertEqual(proof.summarize_output(stdout, stderr), "Tests 116 passed (116)")
|
||||
|
||||
def test_main_json_and_report(self) -> None:
|
||||
original_run_proof = proof.run_proof
|
||||
try:
|
||||
proof.run_proof = lambda _args: [ # type: ignore[assignment]
|
||||
proof.CommandResult(
|
||||
name="fake",
|
||||
cwd="/tmp",
|
||||
command=["echo", "fake"],
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
detail="pass",
|
||||
)
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report_path = Path(tmp) / "report.md"
|
||||
exit_code, output = run_main(["--report-out", str(report_path), "--json"])
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(json.loads(output)[0]["name"], "fake")
|
||||
report = report_path.read_text(encoding="utf-8")
|
||||
self.assertIn("Paid Feature Claim Automated Proof", report)
|
||||
self.assertIn("self-hosted paid claims", report)
|
||||
finally:
|
||||
proof.run_proof = original_run_proof # type: ignore[assignment]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
17
scripts/release_control/paid_feature_claims_proof.py
Normal file
17
scripts/release_control/paid_feature_claims_proof.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
namespace = runpy.run_path(
|
||||
str(Path(__file__).resolve().parent / "internal" / "paid_feature_claims_proof.py")
|
||||
)
|
||||
return namespace["main"](argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -13,6 +13,7 @@ class ProofEntrypointsTest(unittest.TestCase):
|
|||
entrypoints = [
|
||||
"commercial_cancellation_reactivation_proof.py",
|
||||
"commercial_cancellation_reactivation_rehearsal.py",
|
||||
"paid_feature_claims_proof.py",
|
||||
"mobile_relay_auth_approvals_proof.py",
|
||||
"relay_registration_reconnect_drain_proof.py",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue