mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(commercial): attribute in-app checkouts to the surface that started them
Stripe checkouts could not be told apart by origin: every session was stamped checkout_origin pulserelay_landing, so an upgrade started from an RBAC gate looked identical to one started from the website. Gate CTAs now stamp a closed-vocabulary source token (gate-<feature>, estate-card, or the plans-page default) onto the owned billing plan route; the plan page threads it into the purchase-start handoff, and the license server persists it on the checkout intent and stamps Stripe metadata checkout_source. Attribution is authenticated-session-only by construction. The public /pricing route and the public pricing URL never carry it, and getSelfHostedPurchaseStartUrl scrubs the parameter from forwarded query strings so a crafted website link cannot claim in-app origin. Server-side, source is validated against the same kebab vocabulary, skip-listed from the Pulse Account portal redirect exactly as feature already is, and travels only inside the handoff body. It is request-scoped and persists nothing on the install; the cancel return echoes it so a retry keeps its origin. The handoff field is omitempty because the license server decodes strictly, so source-less installs stay compatible with a server either side of the field being added. That ordering is recorded in pulse-pro OPERATIONS.md.
This commit is contained in:
@@ -5945,3 +5945,11 @@ unified resources) and the pinned thresholds
|
||||
(`TestContract_BusinessScaleEstateThresholds`) are identical; only the source
|
||||
of the numbers moved, so the card cohort and the telemetry cohort cannot
|
||||
drift.
|
||||
### Purchase-start attribution does not touch agent routes
|
||||
|
||||
The `source` attribution parameter added to
|
||||
`internal/api/licensing_handlers.go` (an extension point of this subsystem's
|
||||
`internal/api/` surface) is confined to the self-hosted purchase handoff:
|
||||
it is read from the purchase-start query, validated, and passed to the
|
||||
license-server portal handoff. No agent enrollment, report, ack, or update
|
||||
route reads or emits it, and the agent-facing payload shapes are unchanged.
|
||||
|
||||
@@ -8984,3 +8984,20 @@ router echoes it end-to-end on enrollment acks
|
||||
(`TestHostAgentRemovalLifecycleThroughAuthenticatedRouterAndRestart`). Agents
|
||||
use it to trigger an immediate self-update check after a server upgrade. The
|
||||
field is additive: agents that predate it ignore it.
|
||||
### Checkout start forwards attribution in the handoff body, never the portal URL
|
||||
|
||||
`GET /auth/license-purchase-start` accepts an optional `source` query
|
||||
parameter alongside `feature`. It is validated in
|
||||
`internal/api/licensing_handlers.go` against the same closed kebab vocabulary
|
||||
the frontend emits (`normalizeSelfHostedPurchaseSource`); anything else is
|
||||
dropped rather than forwarded. Like `feature`, `source` is added to the
|
||||
skip list in `pulseAccountUpgradeURLForRequest`, so it never reaches the
|
||||
browser-visible Pulse Account portal redirect; it travels only inside the
|
||||
`CreateCheckoutPortalHandoff` request body, where `omitempty` keeps
|
||||
source-less installs compatible with a license server that predates the
|
||||
field. The cancelled-checkout return URL echoes a valid source back onto the
|
||||
plan route (`licensePurchaseActivationRedirectPathWithSource`) so a retry
|
||||
keeps the surface that started it. Unrelated query parameters keep their
|
||||
existing pass-through behavior.
|
||||
`TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal` pins
|
||||
both the forwarding and the drop of a malformed value.
|
||||
|
||||
@@ -1405,6 +1405,29 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`.
|
||||
scope-limited session gets the temporary header reveal instead. The
|
||||
blocked-route redirect and the banner block must stay active for a
|
||||
scope-limited session even when kiosk is off.
|
||||
30. Keep checkout source attribution closed-vocabulary, authenticated-only,
|
||||
and out of browser-visible portal URLs. In-app upgrade CTAs stamp a
|
||||
`source` token (`gate-<feature>`, `estate-card`, or the `plans-page`
|
||||
default) onto the owned billing plan route and the authenticated
|
||||
`/auth/license-purchase-start` URL
|
||||
(`frontend-modern/src/utils/pricingHandoff.ts`); the public `/pricing`
|
||||
route and the public pricing URL never carry it, and
|
||||
`getSelfHostedPurchaseStartUrl` scrubs it from forwarded query strings so
|
||||
a crafted website link cannot claim in-app attribution. Server-side,
|
||||
`HandleCheckoutStart` validates the token against the closed kebab
|
||||
pattern, sends it only inside the `CreateCheckoutPortalHandoff` body
|
||||
(`omitempty`, so source-less requests stay compatible with older license
|
||||
servers), skip-lists it from the Pulse Account portal redirect query, and
|
||||
echoes it onto the cancel return so a retry keeps its origin
|
||||
(`TestHandleCheckoutStart_SourceAttributionReachesHandoffNeverPortal`).
|
||||
The license server persists it on the checkout intent and stamps Stripe
|
||||
session metadata `checkout_source`, with `checkout_origin` now honestly
|
||||
split: `pulse_app` for intent-resolved sessions, `pulserelay_landing`
|
||||
only for the public landing funnel
|
||||
(`TestHandleCheckoutSessionCreate_CarriesSourceAttributionToStripe`,
|
||||
`TestCreateStripeCheckoutSessionKeepsLandingOriginWithoutIntent`).
|
||||
Deploy ordering: the license server must carry the `source` field before
|
||||
a Pulse release that sends it, because the handoff body decodes strictly.
|
||||
|
||||
## Current State
|
||||
|
||||
|
||||
@@ -5045,3 +5045,12 @@ schema-v8 telemetry `business_estate` field. The capability read path stays a
|
||||
pure in-memory classification over monitor state — no storage surface, store
|
||||
handle, or persistence behavior is introduced or altered
|
||||
(`TestContract_BusinessScaleEstateThresholds` pins the unchanged thresholds).
|
||||
### Purchase-start attribution persists nothing on the install
|
||||
|
||||
The `source` attribution parameter handled in
|
||||
`internal/api/licensing_handlers.go` (named in this contract's extension
|
||||
points) is request-scoped: it is validated, forwarded in the portal-handoff
|
||||
body, and echoed onto the cancel return URL. It is never written to config
|
||||
persistence, the license store, or any recovery artifact, so the install's
|
||||
storage surface is unchanged. Attribution is retained only by the commercial
|
||||
backend, on the checkout intent it already owns.
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "2aa43058b7bb700dcd19b8068178e92d14ded583",
|
||||
"verified_at": "2026-08-07T14:03:33Z",
|
||||
"base_sha": "5b07bdc3d8eab45ee004c3c71f22fd2f4d318371",
|
||||
"verified_at": "2026-08-07T18:40:21Z",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/settings.ts"
|
||||
"frontend-modern/src/components/BusinessEstateCard.tsx",
|
||||
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
|
||||
"frontend-modern/src/utils/pricingHandoff.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/settings.ts": "de9f9d21e26270464bc7402a969dd56f356494e976e4bb483884850d8b148901"
|
||||
"frontend-modern/src/components/BusinessEstateCard.tsx": "a3717b19a728ef33f775ccce714080b6affd0a56a3a3d8d7a678625a9a6af900",
|
||||
"frontend-modern/src/components/Settings/useProLicensePanelState.ts": "bfed84fd6aefa15e7b2f7b3ba2c91d8f6e2379172c45bbce3822c7cb1151f776",
|
||||
"frontend-modern/src/utils/pricingHandoff.ts": "7e5c6178f8811179d64894ab136a2ff4263181b61bdc7298a2d341aca9e9b0cc"
|
||||
},
|
||||
"routes": [
|
||||
"/",
|
||||
"/settings (System > General panel via settings search)"
|
||||
"/settings (Security > Roles, RBAC inline gate)",
|
||||
"/settings (System > General, branding gate)",
|
||||
"/settings/pulse-intelligence/billing/plan?source=gate-rbac",
|
||||
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan&source=gate-rbac",
|
||||
"/settings/pulse-intelligence/billing/plan?intent=self_hosted_plan"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -24,13 +32,18 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"free Community tier served by an isolated worktree backend (PULSE_MOCK_MODE=true, 6 mock PVE nodes) on port 7698",
|
||||
"sessionCapabilities.businessEstate=true confirmed via /api/security/status after the threshold helper moved to internal/monitoring",
|
||||
"telemetry preview payload rendered in Settings > System > General showing schema_version 8 with business_estate: true alongside pve_nodes: 6 and paid_license: false"
|
||||
"free Community tier with presentationPolicy.hideUpgrade=false, served by an isolated worktree backend on port 7698 with 6 mock PVE nodes",
|
||||
"RBAC inline feature gate rendered on the Roles panel for an unentitled install",
|
||||
"plan route reached with gate attribution present, and separately with no source at all to exercise the plans-page default",
|
||||
"business-estate card in its eligible state (star prompt already dismissed, first-seen recorded on a prior day)",
|
||||
"branding gate for white_label, an unmapped feature key that routes straight to purchase-start rather than the plan page"
|
||||
],
|
||||
"interactions": [
|
||||
"logged in as admin, opened Settings, searched for General, opened the Usage data and privacy section",
|
||||
"clicked Preview payload and read the full rendered CURRENT HEARTBEAT PAYLOAD JSON including the new business_estate field",
|
||||
"re-exercised the plan route and workspace at 375x812 to confirm the settings surfaces still render at narrow width"
|
||||
"clicked the RBAC gate 'View plans' CTA and confirmed its href carried ?source=gate-rbac, then confirmed the source survived canonical billing-route resolution on arrival",
|
||||
"read the purchase-start hrefs rendered on the plan page: /auth/license-purchase-start?source=gate-rbac&feature=self_hosted_plan",
|
||||
"loaded the same plan route with no source parameter and confirmed the purchase-start hrefs fell back to source=plans-page",
|
||||
"clicked 'See business plans' on the business-estate card and confirmed it navigated to the plan route with ?source=estate-card and dismissed the card permanently",
|
||||
"inspected the branding gate href and confirmed unmapped keys carry attribution too: /auth/license-purchase-start?source=gate-white-label&feature=white_label",
|
||||
"re-exercised the attributed plan route at 375x812 and confirmed the purchase-start hrefs and layout hold at narrow width"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { sessionCapabilities } from '@/stores/sessionCapabilities';
|
||||
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
||||
import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff';
|
||||
import {
|
||||
getSelfHostedBillingHref,
|
||||
PURCHASE_HANDOFF_SOURCE_ESTATE_CARD,
|
||||
} from '@/utils/pricingHandoff';
|
||||
import { ActionIconButton, Button } from '@/components/shared/Button';
|
||||
import BriefcaseIcon from 'lucide-solid/icons/briefcase';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
@@ -104,7 +107,7 @@ export function BusinessEstateCard() {
|
||||
const handleSeePlans = () => {
|
||||
setDismissed(true);
|
||||
setShowCard(false);
|
||||
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE);
|
||||
navigate(getSelfHostedBillingHref('plan', { source: PURCHASE_HANDOFF_SOURCE_ESTATE_CARD }));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getSelfHostedBillingHref,
|
||||
getPublicPricingUrl,
|
||||
getSelfHostedPurchaseStartUrl,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED,
|
||||
@@ -1150,7 +1151,11 @@ describe('ProLicensePanel', () => {
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED,
|
||||
title: 'Checkout cancelled',
|
||||
actionLabel: 'View plans',
|
||||
actionHref: getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
|
||||
actionHref: getSelfHostedPurchaseStartUrl(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
undefined,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
),
|
||||
redirectedHref: getSelfHostedBillingHref('plan', {
|
||||
intent: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
}),
|
||||
@@ -1159,7 +1164,11 @@ describe('ProLicensePanel', () => {
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED,
|
||||
title: 'Upgrade return expired',
|
||||
actionLabel: 'View plans',
|
||||
actionHref: getSelfHostedPurchaseStartUrl(),
|
||||
actionHref: getSelfHostedPurchaseStartUrl(
|
||||
undefined,
|
||||
undefined,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
),
|
||||
},
|
||||
{
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
|
||||
@@ -1176,7 +1185,11 @@ describe('ProLicensePanel', () => {
|
||||
purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE,
|
||||
title: 'Pulse Account unavailable',
|
||||
actionLabel: 'Try again',
|
||||
actionHref: getSelfHostedPurchaseStartUrl(),
|
||||
actionHref: getSelfHostedPurchaseStartUrl(
|
||||
undefined,
|
||||
undefined,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
),
|
||||
},
|
||||
])(
|
||||
'shows the purchase arrival notice for $purchase',
|
||||
@@ -1432,7 +1445,11 @@ describe('ProLicensePanel', () => {
|
||||
compareLinks.some(
|
||||
(link) =>
|
||||
link.getAttribute('href') ===
|
||||
getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
|
||||
getSelfHostedPurchaseStartUrl(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
undefined,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole('button', { name: 'Hide counting rules' })).not.toBeInTheDocument();
|
||||
|
||||
@@ -32,7 +32,9 @@ import {
|
||||
getSelfHostedBillingHref,
|
||||
getSelfHostedBillingPlanDetail,
|
||||
getSelfHostedBillingPlanIntent,
|
||||
getSelfHostedBillingPlanSource,
|
||||
getSelfHostedBillingPurchaseArrival,
|
||||
PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
resolveSelfHostedBillingSection,
|
||||
resolveSelfHostedPurchaseStartDestination,
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
@@ -164,6 +166,12 @@ export function useProLicensePanelState() {
|
||||
resolveSelfHostedBillingSection(location.pathname, location.search, location.hash),
|
||||
);
|
||||
|
||||
// Checkout attribution: a gate CTA or the estate card stamps ?source= onto
|
||||
// the plan route; organic arrivals attribute to the plans page itself.
|
||||
const purchaseHandoffSource = createMemo(
|
||||
() => getSelfHostedBillingPlanSource(location.search) ?? PURCHASE_HANDOFF_SOURCE_PLANS_PAGE,
|
||||
);
|
||||
|
||||
const activeSection = createMemo<SelfHostedBillingSection>(() => {
|
||||
return requestedSection() === 'usage' ? 'plan' : requestedSection();
|
||||
});
|
||||
@@ -207,6 +215,8 @@ export function useProLicensePanelState() {
|
||||
actionLabel: SELF_HOSTED_PRO_BILLING_PRESENTATION.planSelectionPromptActionLabel,
|
||||
actionDestination: resolveSelfHostedPurchaseStartDestination(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
undefined,
|
||||
purchaseHandoffSource(),
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -331,12 +341,20 @@ export function useProLicensePanelState() {
|
||||
case SELF_HOSTED_PRO_BILLING_PURCHASE_CANCELLED:
|
||||
return {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseCancelledActionLabel,
|
||||
destination: resolveSelfHostedPurchaseStartDestination(intent),
|
||||
destination: resolveSelfHostedPurchaseStartDestination(
|
||||
intent,
|
||||
undefined,
|
||||
purchaseHandoffSource(),
|
||||
),
|
||||
};
|
||||
case SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED:
|
||||
return {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseExpiredActionLabel,
|
||||
destination: resolveSelfHostedPurchaseStartDestination(intent),
|
||||
destination: resolveSelfHostedPurchaseStartDestination(
|
||||
intent,
|
||||
undefined,
|
||||
purchaseHandoffSource(),
|
||||
),
|
||||
};
|
||||
case SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED:
|
||||
return {
|
||||
@@ -351,7 +369,11 @@ export function useProLicensePanelState() {
|
||||
case SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE:
|
||||
return {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseUnavailableActionLabel,
|
||||
destination: resolveSelfHostedPurchaseStartDestination(intent),
|
||||
destination: resolveSelfHostedPurchaseStartDestination(
|
||||
intent,
|
||||
undefined,
|
||||
purchaseHandoffSource(),
|
||||
),
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
@@ -410,6 +432,8 @@ export function useProLicensePanelState() {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
|
||||
destination: resolveSelfHostedPurchaseStartDestination(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
undefined,
|
||||
purchaseHandoffSource(),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -175,7 +175,9 @@ describe('BusinessEstateCard', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'See business plans' }));
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan');
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/settings/pulse-intelligence/billing/plan?source=estate-card',
|
||||
);
|
||||
expect(queryCard()).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? 'false')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -46,19 +46,27 @@ describe('pricingHandoff', () => {
|
||||
expect(getInProductPricingDestination('cloud')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('routes paid self-hosted feature upgrades to the in-product billing plan page', () => {
|
||||
expect(getUpgradeFallbackDestination('relay')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getUpgradeFallbackDestination('mobile_app')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
it('routes paid self-hosted feature upgrades to the in-product billing plan page with gate attribution', () => {
|
||||
expect(getUpgradeFallbackDestination('relay')).toBe(
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-relay`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('mobile_app')).toBe(
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-mobile-app`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('push_notifications')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-push-notifications`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('ai_alerts')).toBe(
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-ai-alerts`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('ai_alerts')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getUpgradeFallbackDestination('ai_autofix')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('rbac')).toBe(
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`,
|
||||
);
|
||||
expect(getUpgradeFallbackDestination('rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
expect(getUpgradeFallbackDestination('advanced_reporting')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-reporting`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,15 +91,61 @@ describe('pricingHandoff', () => {
|
||||
'agent_profiles',
|
||||
'external_probe',
|
||||
]);
|
||||
const expectedGateSources: Record<string, string> = {
|
||||
relay: 'gate-relay',
|
||||
mobile_app: 'gate-mobile-app',
|
||||
push_notifications: 'gate-push-notifications',
|
||||
ai_alerts: 'gate-ai-alerts',
|
||||
ai_autofix: 'gate-ai-autofix',
|
||||
long_term_metrics: 'gate-long-term-metrics',
|
||||
rbac: 'gate-rbac',
|
||||
audit_logging: 'gate-audit-logging',
|
||||
advanced_reporting: 'gate-reporting',
|
||||
agent_profiles: 'gate-agent-profiles',
|
||||
external_probe: 'gate-external-probe',
|
||||
};
|
||||
for (const key of paidCatalogFeatureKeys) {
|
||||
expect(getUpgradeFallbackDestination(key)).toBe(
|
||||
key === 'ai_autofix'
|
||||
? SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF
|
||||
: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
? `${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF}&source=gate-ai-autofix`
|
||||
: `${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=${expectedGateSources[key]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('threads checkout source attribution through owned billing hrefs and purchase start', () => {
|
||||
// Plan hrefs carry a validated source; the usage section never does.
|
||||
expect(getSelfHostedBillingHref('plan', { source: 'estate-card' })).toBe(
|
||||
`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=estate-card`,
|
||||
);
|
||||
expect(getSelfHostedBillingHref('usage', { source: 'estate-card' })).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
|
||||
);
|
||||
// Canonical route resolution preserves a valid plan-route source.
|
||||
expect(
|
||||
resolveCanonicalSelfHostedBillingHref(SELF_HOSTED_PRO_BILLING_PLAN_HREF, '?source=gate-rbac'),
|
||||
).toBe(`${SELF_HOSTED_PRO_BILLING_PLAN_HREF}?source=gate-rbac`);
|
||||
// An explicit source lands on the purchase-start URL.
|
||||
expect(getSelfHostedPurchaseStartUrl('self_hosted_plan', undefined, 'plans-page')).toBe(
|
||||
`${SELF_HOSTED_PURCHASE_START_PATH}?source=plans-page&feature=self_hosted_plan`,
|
||||
);
|
||||
// A forwarded query string never smuggles attribution in (public /pricing).
|
||||
expect(
|
||||
getSelfHostedPurchaseStartUrl(
|
||||
'unknown_pro_feature',
|
||||
new URLSearchParams('source=gate-rbac&utm_content=legacy-bookmark'),
|
||||
),
|
||||
).toBe(
|
||||
`${SELF_HOSTED_PURCHASE_START_PATH}?utm_content=legacy-bookmark&feature=unknown_pro_feature`,
|
||||
);
|
||||
// Malformed sources are dropped rather than forwarded.
|
||||
expect(getSelfHostedPurchaseStartUrl('relay', undefined, 'Bad Source!')).toBe(
|
||||
`${SELF_HOSTED_PURCHASE_START_PATH}?feature=relay`,
|
||||
);
|
||||
// The public /pricing resolver stays unsourced for mapped features.
|
||||
expect(getPricingRouteDestination('?feature=rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
|
||||
});
|
||||
|
||||
it('keeps retired trial-expired out of upgrade fallbacks while preserving neutral legacy arrival', () => {
|
||||
expect(getPricingRouteDestination('?feature=trial_expired')).toBe(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM =
|
||||
export const SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM =
|
||||
SELF_HOSTED_PRO_BILLING_DETAILS_QUERY_PARAM;
|
||||
export const SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM = 'intent';
|
||||
export const SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM = 'source';
|
||||
export const SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM = 'purchase';
|
||||
export const SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL = 'counting-rules';
|
||||
export const SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL = 'recovery';
|
||||
@@ -53,6 +54,49 @@ export const SELF_HOSTED_PRO_BILLING_PLAN_RECOVERY_HREF = `${SELF_HOSTED_PRO_BIL
|
||||
export const SELF_HOSTED_PRO_BILLING_USAGE_COUNTING_RULES_HREF = `${SELF_HOSTED_PRO_BILLING_USAGE_ROUTE}?${SELF_HOSTED_PRO_BILLING_USAGE_DETAILS_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL}`;
|
||||
export const SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF = `${SELF_HOSTED_PRO_BILLING_PLAN_ROUTE}?${SELF_HOSTED_PRO_BILLING_PLAN_INTENT_QUERY_PARAM}=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`;
|
||||
|
||||
// Checkout source attribution. Gate CTAs stamp a closed-vocabulary `source`
|
||||
// onto the billing plan route (or directly onto the purchase-start URL for
|
||||
// unmapped keys); the plan page threads it into the purchase-start handoff so
|
||||
// the license server can attribute the Stripe checkout to the surface that
|
||||
// started it. The param is authenticated-session-only: the public /pricing
|
||||
// route and the public pricing URL never carry it, and forwarded pre-auth
|
||||
// query strings are scrubbed of it in getSelfHostedPurchaseStartUrl.
|
||||
export const PURCHASE_HANDOFF_SOURCE_PLANS_PAGE = 'plans-page';
|
||||
export const PURCHASE_HANDOFF_SOURCE_ESTATE_CARD = 'estate-card';
|
||||
|
||||
const PURCHASE_HANDOFF_GATE_SOURCES: Record<string, string> = {
|
||||
rbac: 'gate-rbac',
|
||||
advanced_reporting: 'gate-reporting',
|
||||
mobile_app: 'gate-mobile-app',
|
||||
push_notifications: 'gate-push-notifications',
|
||||
ai_alerts: 'gate-ai-alerts',
|
||||
ai_autofix: 'gate-ai-autofix',
|
||||
relay: 'gate-relay',
|
||||
audit_logging: 'gate-audit-logging',
|
||||
agent_profiles: 'gate-agent-profiles',
|
||||
external_probe: 'gate-external-probe',
|
||||
long_term_metrics: 'gate-long-term-metrics',
|
||||
white_label: 'gate-white-label',
|
||||
};
|
||||
|
||||
const PURCHASE_HANDOFF_SOURCE_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
|
||||
|
||||
export function normalizePurchaseHandoffSource(
|
||||
source: string | null | undefined,
|
||||
): string | undefined {
|
||||
const normalized = source?.trim();
|
||||
if (!normalized || !PURCHASE_HANDOFF_SOURCE_PATTERN.test(normalized)) return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getPurchaseHandoffGateSource(
|
||||
feature: string | null | undefined,
|
||||
): string | undefined {
|
||||
const normalizedFeature = feature?.trim();
|
||||
if (!normalizedFeature) return undefined;
|
||||
return PURCHASE_HANDOFF_GATE_SOURCES[normalizedFeature];
|
||||
}
|
||||
|
||||
const IN_PRODUCT_PRICING_DESTINATIONS: Record<string, string> = {
|
||||
self_hosted_plan: SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF,
|
||||
// Paid self-hosted feature keys: route to the owned billing plan page instead
|
||||
@@ -119,6 +163,7 @@ function normalizeFeatureKey(feature: string | null | undefined): string | undef
|
||||
export function getSelfHostedPurchaseStartUrl(
|
||||
feature?: string | null,
|
||||
searchParams?: URLSearchParams,
|
||||
source?: string | null,
|
||||
): string {
|
||||
const url = new URL(SELF_HOSTED_PURCHASE_START_PATH, INTERNAL_HREF_BASE);
|
||||
if (searchParams) {
|
||||
@@ -130,6 +175,15 @@ export function getSelfHostedPurchaseStartUrl(
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution is set only by authenticated in-app callers via the explicit
|
||||
// argument; a forwarded query string (the public /pricing route) never
|
||||
// carries it through.
|
||||
url.searchParams.delete(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM);
|
||||
const normalizedSource = normalizePurchaseHandoffSource(source);
|
||||
if (normalizedSource) {
|
||||
url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalizedSource);
|
||||
}
|
||||
|
||||
const normalizedFeature = normalizeFeatureKey(feature);
|
||||
if (normalizedFeature) {
|
||||
url.searchParams.set('feature', normalizedFeature);
|
||||
@@ -140,8 +194,9 @@ export function getSelfHostedPurchaseStartUrl(
|
||||
export function resolveSelfHostedPurchaseStartDestination(
|
||||
feature?: string | null,
|
||||
searchParams?: URLSearchParams,
|
||||
source?: string | null,
|
||||
): UpgradeDestination {
|
||||
return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams), {
|
||||
return resolveUpgradeDestination(getSelfHostedPurchaseStartUrl(feature, searchParams, source), {
|
||||
hardNavigation: true,
|
||||
newTab: true,
|
||||
preserveOpener: true,
|
||||
@@ -174,12 +229,30 @@ export function getManagedHostingRequestUrl(): string {
|
||||
return DEFAULT_MANAGED_HOSTING_REQUEST_URL;
|
||||
}
|
||||
|
||||
// withPurchaseHandoffSource stamps a gate source onto an in-product billing
|
||||
// plan href. Only the authenticated upgrade-CTA fallback below uses it; the
|
||||
// public /pricing route resolves the same destinations unsourced so a website
|
||||
// arrival is never attributed to an in-app gate.
|
||||
function withPurchaseHandoffSource(destination: string, source: string | undefined): string {
|
||||
const normalized = normalizePurchaseHandoffSource(source);
|
||||
if (!normalized) return destination;
|
||||
const url = new URL(destination, INTERNAL_HREF_BASE);
|
||||
if (!isSelfHostedBillingPath(url.pathname)) return destination;
|
||||
url.searchParams.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, normalized);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function getUpgradeFallbackDestination(feature?: string | null): string | undefined {
|
||||
if (isRetiredPricingFeature(feature)) return undefined;
|
||||
if (normalizeFeatureKey(feature) === 'cloud') {
|
||||
return getManagedHostingRequestUrl();
|
||||
}
|
||||
return getInProductPricingDestination(feature) || getSelfHostedPurchaseStartUrl(feature);
|
||||
const gateSource = getPurchaseHandoffGateSource(feature);
|
||||
const inProductDestination = getInProductPricingDestination(feature);
|
||||
if (inProductDestination) {
|
||||
return withPurchaseHandoffSource(inProductDestination, gateSource);
|
||||
}
|
||||
return getSelfHostedPurchaseStartUrl(feature, undefined, gateSource);
|
||||
}
|
||||
|
||||
export function getSelfHostedBillingUsageDetail(
|
||||
@@ -206,6 +279,14 @@ export function getSelfHostedBillingPlanIntent(search: string): SelfHostedBillin
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSelfHostedBillingPlanSource(search: string): string | null {
|
||||
return (
|
||||
normalizePurchaseHandoffSource(
|
||||
billingSearch(search).get(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function getSelfHostedBillingPurchaseArrival(
|
||||
search: string,
|
||||
): SelfHostedBillingPurchaseArrival | null {
|
||||
@@ -228,6 +309,7 @@ export function getSelfHostedBillingHref(
|
||||
detail?: SelfHostedBillingDetail | null;
|
||||
intent?: SelfHostedBillingPlanIntent | null;
|
||||
purchase?: SelfHostedBillingPurchaseArrival | null;
|
||||
source?: string | null;
|
||||
} = {},
|
||||
): string {
|
||||
const baseRoute =
|
||||
@@ -259,6 +341,11 @@ export function getSelfHostedBillingHref(
|
||||
params.set(SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM, options.purchase);
|
||||
}
|
||||
|
||||
const source = section === 'plan' ? normalizePurchaseHandoffSource(options.source) : undefined;
|
||||
if (source) {
|
||||
params.set(SELF_HOSTED_PRO_BILLING_PLAN_SOURCE_QUERY_PARAM, source);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
return `${baseRoute}${search ? `?${search}` : ''}`;
|
||||
}
|
||||
@@ -326,6 +413,7 @@ export function resolveCanonicalSelfHostedBillingHref(
|
||||
? SELF_HOSTED_PRO_BILLING_COUNTING_RULES_DETAIL
|
||||
: null;
|
||||
const planIntent = section === 'plan' ? getSelfHostedBillingPlanIntent(search) : null;
|
||||
const planSource = section === 'plan' ? getSelfHostedBillingPlanSource(search) : null;
|
||||
const planDetail =
|
||||
section === 'plan' &&
|
||||
(getSelfHostedBillingPlanDetail(search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL ||
|
||||
@@ -338,6 +426,7 @@ export function resolveCanonicalSelfHostedBillingHref(
|
||||
detail: section === 'usage' ? usageDetail : planDetail,
|
||||
intent: planIntent,
|
||||
purchase,
|
||||
source: planSource,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22520,3 +22520,73 @@ func TestUnifiedAgentHandlers_HandleReportAckOmitsEmptyServerVersion(t *testing.
|
||||
t.Fatalf("ack unexpectedly carries serverVersion: %v", ack["serverVersion"])
|
||||
}
|
||||
}
|
||||
|
||||
// Checkout source attribution is an in-app funnel signal, not a browser-visible
|
||||
// one: it must reach the license server only inside the portal-handoff body and
|
||||
// must never appear on the Pulse Account portal redirect the browser follows.
|
||||
func TestContract_CheckoutStartSourceAttributionReachesHandoffNeverPortal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
querySource string
|
||||
wantSource string
|
||||
wantCancelURL string
|
||||
}{
|
||||
{
|
||||
name: "valid gate source",
|
||||
querySource: "gate-rbac",
|
||||
wantSource: "gate-rbac",
|
||||
wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled&source=gate-rbac",
|
||||
},
|
||||
{
|
||||
name: "malformed source is dropped",
|
||||
querySource: "Bad Source!",
|
||||
wantSource: "",
|
||||
wantCancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := createTestHandler(t)
|
||||
handler.SetConfig(&config.Config{PublicURL: "https://pulse.example.com"})
|
||||
var capturedReq struct {
|
||||
Feature string `json:"feature"`
|
||||
CancelURL string `json:"cancel_url"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&capturedReq); err != nil {
|
||||
t.Fatalf("decode checkout portal handoff request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"portal_handoff_id": "cph_test_source"})
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("PULSE_LICENSE_SERVER_URL", server.URL)
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"https://pulse.example.com/auth/license-purchase-start?feature=rbac&source="+url.QueryEscape(tc.querySource),
|
||||
nil,
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleCheckoutStart(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusSeeOther, rec.Body.String())
|
||||
}
|
||||
redirectURL, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse redirect location: %v", err)
|
||||
}
|
||||
if got := redirectURL.Query().Get("source"); got != "" {
|
||||
t.Fatalf("source = %q, want omitted from portal query", got)
|
||||
}
|
||||
if capturedReq.Source != tc.wantSource {
|
||||
t.Fatalf("handoff source = %q, want %q", capturedReq.Source, tc.wantSource)
|
||||
}
|
||||
if capturedReq.CancelURL != tc.wantCancelURL {
|
||||
t.Fatalf("cancel_url = %q, want %q", capturedReq.CancelURL, tc.wantCancelURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,6 +30,7 @@ const (
|
||||
licensePurchaseReturnTokenField = "purchase_return_token"
|
||||
pulseAccountUpgradeService = "upgrade"
|
||||
pulseAccountPortalFeatureQueryParam = "feature"
|
||||
pulseAccountPortalSourceQueryParam = "source"
|
||||
pulseAccountPortalHandoffIDField = "portal_handoff_id"
|
||||
pulseAccountPortalHandoffURLQueryParam = "purchase_handoff_url"
|
||||
pulseAccountPortalServiceQueryParam = "service"
|
||||
@@ -292,6 +294,7 @@ func pulseAccountUpgradeURLForRequest(portalHandoffID string, query url.Values)
|
||||
for key, values := range query {
|
||||
switch key {
|
||||
case pulseAccountPortalFeatureQueryParam,
|
||||
pulseAccountPortalSourceQueryParam,
|
||||
pulseAccountPortalHandoffIDField,
|
||||
pulseAccountPortalHandoffURLQueryParam,
|
||||
pulseAccountPortalServiceQueryParam,
|
||||
@@ -354,13 +357,36 @@ func normalizeSelfHostedPurchaseFeature(feature string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// selfHostedPurchaseSourcePattern mirrors the frontend attribution vocabulary
|
||||
// (pricingHandoff.ts): lowercase kebab tokens such as "gate-rbac",
|
||||
// "estate-card", or "plans-page". Anything else is dropped, not forwarded.
|
||||
var selfHostedPurchaseSourcePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
||||
|
||||
func normalizeSelfHostedPurchaseSource(source string) string {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if !selfHostedPurchaseSourcePattern.MatchString(trimmed) {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func licensePurchaseActivationRedirectPath(feature, purchaseResult string) string {
|
||||
return licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, "")
|
||||
}
|
||||
|
||||
// licensePurchaseActivationRedirectPathWithSource additionally carries the
|
||||
// checkout source attribution back onto the plan route so a retry after a
|
||||
// cancelled checkout keeps the surface that originally drove it.
|
||||
func licensePurchaseActivationRedirectPathWithSource(feature, purchaseResult, source string) string {
|
||||
normalizedFeature := normalizeSelfHostedPurchaseFeature(feature)
|
||||
query := url.Values{}
|
||||
switch normalizedFeature {
|
||||
case "self_hosted_plan":
|
||||
query.Set("intent", "self_hosted_plan")
|
||||
}
|
||||
if normalizedSource := normalizeSelfHostedPurchaseSource(source); normalizedSource != "" {
|
||||
query.Set(pulseAccountPortalSourceQueryParam, normalizedSource)
|
||||
}
|
||||
switch strings.TrimSpace(purchaseResult) {
|
||||
case selfHostedBillingPurchaseActivated,
|
||||
selfHostedBillingPurchaseCancelled,
|
||||
@@ -1075,6 +1101,9 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
|
||||
feature := normalizeSelfHostedPurchaseFeature(
|
||||
r.URL.Query().Get(pulseAccountPortalFeatureQueryParam),
|
||||
)
|
||||
purchaseSource := normalizeSelfHostedPurchaseSource(
|
||||
r.URL.Query().Get(pulseAccountPortalSourceQueryParam),
|
||||
)
|
||||
writeUnavailable := func(statusCode int, message string) {
|
||||
writeLicensePurchaseStartFailurePage(w, statusCode, feature, message)
|
||||
}
|
||||
@@ -1131,7 +1160,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
|
||||
cancelURL, err := publicAbsoluteURLForPath(
|
||||
r,
|
||||
h.cfg,
|
||||
licensePurchaseActivationRedirectPath(feature, selfHostedBillingPurchaseCancelled),
|
||||
licensePurchaseActivationRedirectPathWithSource(feature, selfHostedBillingPurchaseCancelled, purchaseSource),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("feature", feature).Msg("Failed to build Pulse Account cancellation return url")
|
||||
@@ -1169,6 +1198,7 @@ func (h *LicenseHandlers) HandleCheckoutStart(w http.ResponseWriter, r *http.Req
|
||||
SuccessURL: activationURLTemplate,
|
||||
CancelURL: cancelURL,
|
||||
PurchaseReturnJTI: purchaseReturnJTI,
|
||||
Source: purchaseSource,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("feature", feature).Msg("Failed to create Pulse Account checkout portal handoff")
|
||||
|
||||
@@ -49,6 +49,11 @@ type CheckoutPortalHandoffRequest struct {
|
||||
SuccessURL string `json:"success_url"`
|
||||
CancelURL string `json:"cancel_url"`
|
||||
PurchaseReturnJTI string `json:"purchase_return_jti"`
|
||||
// Source is the closed-vocabulary in-app surface attribution for the
|
||||
// checkout (e.g. "gate-rbac", "estate-card", "plans-page"). omitempty keeps
|
||||
// requests without attribution compatible with license servers that predate
|
||||
// the field, since the receiver decodes strictly.
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// CheckoutPortalHandoffResponse is the canonical opaque portal handoff used to
|
||||
|
||||
@@ -523,6 +523,54 @@ func TestClientCreateCheckoutPortalHandoff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientCreateCheckoutPortalHandoffSourceAttribution(t *testing.T) {
|
||||
// The license server decodes this body strictly (DisallowUnknownFields), so
|
||||
// an install with no attribution must omit `source` entirely rather than
|
||||
// send an empty string. That keeps source-less handoffs working against a
|
||||
// license server deployed either side of the field being added.
|
||||
cases := []struct {
|
||||
name string
|
||||
source string
|
||||
wantKey bool
|
||||
wantSource string
|
||||
}{
|
||||
{name: "attributed handoff carries the source", source: "gate-rbac", wantKey: true, wantSource: "gate-rbac"},
|
||||
{name: "unattributed handoff omits the key", source: "", wantKey: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(CheckoutPortalHandoffResponse{PortalHandoffID: "cph_test_source"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewLicenseServerClient(server.URL)
|
||||
if _, err := client.CreateCheckoutPortalHandoff(context.Background(), CheckoutPortalHandoffRequest{
|
||||
Feature: "rbac",
|
||||
SuccessURL: "https://pulse.example.com/auth/license-purchase-activate?purchase_return_token=prt_signed&session_id={CHECKOUT_SESSION_ID}",
|
||||
CancelURL: "https://pulse.example.com/settings/system/billing/plan?purchase=cancelled",
|
||||
PurchaseReturnJTI: "purchase_return_jti_123",
|
||||
Source: tc.source,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCheckoutPortalHandoff failed: %v", err)
|
||||
}
|
||||
|
||||
got, present := body["source"]
|
||||
if present != tc.wantKey {
|
||||
t.Fatalf("source key present = %v, want %v (body=%v)", present, tc.wantKey, body)
|
||||
}
|
||||
if tc.wantKey && got != tc.wantSource {
|
||||
t.Fatalf("source = %v, want %q", got, tc.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRefreshGrant(t *testing.T) {
|
||||
t.Run("successful refresh", func(t *testing.T) {
|
||||
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
|
||||
|
||||
Reference in New Issue
Block a user