mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
Prepare v6 demo mode commercial boundary
This commit is contained in:
parent
1db6b41aac
commit
4e4c1b368b
31 changed files with 457 additions and 136 deletions
|
|
@ -301,6 +301,14 @@ This subsystem now sits under the dedicated agent lifecycle and fleet
|
|||
operations lane so install, registration, update continuity, profile
|
||||
management, and fleet safety stop hiding inside architecture, migration, or
|
||||
monitoring work.
|
||||
That same adjacent `internal/api/` boundary now also keeps public demos from
|
||||
leaking commercial state through lifecycle-adjacent surfaces. Agent install,
|
||||
reporting, and setup flows may share backend helpers with billing or license
|
||||
transport, but `DEMO_MODE` must continue to 404 commercial read surfaces
|
||||
instead of teaching lifecycle or mock-mode paths to bypass licensing. Public
|
||||
demo readiness therefore comes from hiding commercial presentation on the
|
||||
shared API boundary, not from introducing a second fake-entitlement path into
|
||||
lifecycle-owned install or reporting flows.
|
||||
Lifecycle-adjacent storage and fleet surfaces now also depend on one governed
|
||||
physical-disk history transport. When agent-backed disk telemetry is rendered
|
||||
through shared drawers or lifecycle-adjacent resource context, those reads
|
||||
|
|
@ -1825,3 +1833,11 @@ may read websocket state only through
|
|||
recreate app-shell providers, because `frontend-modern/src/App.tsx` owns
|
||||
provider placement while lifecycle hooks must stay lazy-load safe and
|
||||
shell-independent.
|
||||
That same adjacent `internal/api/` boundary now also keeps public demos from
|
||||
leaking commercial state through lifecycle-adjacent surfaces. Agent install,
|
||||
reporting, and setup flows may share backend helpers with billing or license
|
||||
transport, but `DEMO_MODE` must continue to 404 commercial read surfaces
|
||||
instead of teaching lifecycle or mock-mode paths to bypass licensing. Public
|
||||
demo readiness therefore comes from hiding commercial presentation on the
|
||||
shared API boundary, not from introducing a second fake-entitlement path into
|
||||
lifecycle-owned install or reporting flows.
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ Own canonical runtime payload shapes between backend and frontend.
|
|||
50. `internal/cloudcp/portal/frontend_sync_test.go`
|
||||
51. `internal/api/recovery_handlers.go`
|
||||
52. `internal/api/config_setup_handlers.go`
|
||||
53. `internal/api/demo_mode_commercial.go`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -2402,3 +2403,13 @@ may read websocket state through `frontend-modern/src/contexts/appRuntime.ts`,
|
|||
but payload truth, bootstrap rules, and commercial identity still belong to
|
||||
the governed API handlers and contract tests. Those hooks must not import
|
||||
`@/App` or treat root-shell ownership as transport authority.
|
||||
That same shared commercial API contract now also owns the public demo
|
||||
read-side boundary. `internal/api/demo_mode_commercial.go`,
|
||||
`internal/api/licensing_handlers.go`,
|
||||
`internal/api/monitored_system_ledger.go`, and
|
||||
`internal/api/subscription_state_handlers.go` must fail closed with a generic
|
||||
`404` for public-demo billing, license-status, and monitored-system-ledger
|
||||
reads whenever `DEMO_MODE` is enabled. Demo runtimes may still use real
|
||||
server-side entitlement evaluation internally, but the governed browser/API
|
||||
contract must not expose commercial identity, usage, or upgrade-state payloads
|
||||
back to public viewers through those read surfaces.
|
||||
|
|
|
|||
|
|
@ -148,6 +148,16 @@ agreement, and cloud-specific enforcement rules.
|
|||
|
||||
Cloud paid readiness is materially behind architecture work. The main concern is
|
||||
contract coherence between pricing, entitlements, and runtime enforcement.
|
||||
That same cloud-paid/browser boundary now also governs public demo posture.
|
||||
`DEMO_MODE` may run against a real internal entitlement, but public demo
|
||||
surfaces must not reveal self-hosted license metadata, hosted billing state,
|
||||
monitored-system ledgers, upgrade nudges, or activation controls just because
|
||||
the underlying runtime is commercially enabled. `frontend-modern/src/utils/apiClient.ts`
|
||||
must treat `X-Demo-Mode` as the canonical browser signal, and shared billing
|
||||
or upgrade surfaces must hide or suppress themselves from that signal rather
|
||||
than teaching mock mode or frontend-only feature flags to bypass the real
|
||||
licensing model. Demo readiness therefore means presentation isolation, not a
|
||||
license exemption.
|
||||
Legacy Cloud plan aliases are now expected to canonicalize to the `cloud_*`
|
||||
contract not only when Stripe metadata is parsed, but also when persisted plan
|
||||
versions are consumed at hosted entitlement and workspace-limit enforcement
|
||||
|
|
|
|||
|
|
@ -396,6 +396,17 @@ bounds, including provider-backed alert-history wording. `frontend-modern/src/fe
|
|||
backed host and VM incidents with the shared `resource-incident` vocabulary
|
||||
and existing alert-history shells instead of introducing VMware-only labels,
|
||||
badges, or panel copy just because the underlying signal came from vSphere.
|
||||
That same shared settings-shell and banner boundary now also owns demo-mode
|
||||
commercial suppression. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`,
|
||||
`frontend-modern/src/components/Settings/settingsNavVisibility.ts`,
|
||||
`frontend-modern/src/components/shared/useTrialBannerState.ts`, and
|
||||
`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`
|
||||
must consume one shared demo-mode truth and hide billing tabs, trial nudges,
|
||||
and monitored-system warning banners when the browser is rendering a public
|
||||
demo runtime. Shared primitives must not perform their own ad hoc `/api/health`
|
||||
polling or rebuild per-banner demo heuristics; the settings shell and shared
|
||||
banner hooks stay on the canonical shared demo-mode owner so suppression stays
|
||||
coherent across customer-facing surfaces.
|
||||
Storage disk drawers now also sit on that same shared-primitives floor.
|
||||
`frontend-modern/src/components/Storage/DiskDetail.tsx` must render physical-
|
||||
disk read, write, and busy charts through `HistoryChart` plus
|
||||
|
|
@ -1819,3 +1830,14 @@ such as `frontend-modern/src/components/Settings/Settings.tsx`,
|
|||
may consume that module, but they must not import `@/App` or recreate shell
|
||||
providers. `frontend-modern/src/App.tsx` owns provider placement; primitives
|
||||
own reusable consumption only.
|
||||
That same shared settings-shell and banner boundary now also owns demo-mode
|
||||
commercial suppression. `frontend-modern/src/components/Settings/settingsNavCatalog.ts`,
|
||||
`frontend-modern/src/components/Settings/settingsNavVisibility.ts`,
|
||||
`frontend-modern/src/components/shared/useTrialBannerState.ts`, and
|
||||
`frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts`
|
||||
must consume one shared demo-mode truth and hide billing tabs, trial nudges,
|
||||
and monitored-system warning banners when the browser is rendering a public
|
||||
demo runtime. Shared primitives must not perform their own ad hoc `/api/health`
|
||||
polling or rebuild per-banner demo heuristics; the settings shell and shared
|
||||
banner hooks stay on the canonical shared demo-mode owner so suppression stays
|
||||
coherent across customer-facing surfaces.
|
||||
|
|
|
|||
|
|
@ -314,6 +314,14 @@ querying, and the operator-facing storage health presentation layer.
|
|||
This subsystem now sits under the dedicated storage and recovery lane so the
|
||||
operator-facing storage page, recovery timeline, and recovery-point persistence
|
||||
engine stop hiding inside broader monitoring and E2E buckets.
|
||||
That same adjacent `internal/api/` boundary now also governs public-demo
|
||||
commercial redaction for storage and recovery viewers. Shared storage/recovery
|
||||
surfaces may run beside a demo runtime that has real internal entitlements,
|
||||
but `DEMO_MODE` must still 404 license-status, billing-state, and monitored-
|
||||
system-ledger reads so adjacent recovery or storage pages do not leak
|
||||
commercial identity or upgrade posture into a public demo. Storage/recovery
|
||||
must consume that redacted boundary as presentation truth rather than
|
||||
reintroducing mock-only license bypasses or page-local commercial fallbacks.
|
||||
Physical-disk live I/O drawers now also sit on the canonical storage surface.
|
||||
Storage disk drawers may show read, write, busy, and SMART history, but every
|
||||
chart must route through the shared `HistoryChart` API contract using the disk
|
||||
|
|
@ -1988,3 +1996,11 @@ through `frontend-modern/src/contexts/appRuntime.ts`. They must not import
|
|||
`@/App` or create storage/recovery-local shell coupling, because provider
|
||||
placement remains app-shell-owned and storage/recovery surfaces must stay
|
||||
lazy-load safe.
|
||||
That same adjacent `internal/api/` boundary now also governs public-demo
|
||||
commercial redaction for storage and recovery viewers. Shared storage/recovery
|
||||
surfaces may run beside a demo runtime that has real internal entitlements,
|
||||
but `DEMO_MODE` must still 404 license-status, billing-state, and monitored-
|
||||
system-ledger reads so adjacent recovery or storage pages do not leak
|
||||
commercial identity or upgrade posture into a public demo. Storage/recovery
|
||||
must consume that redacted boundary as presentation truth rather than
|
||||
reintroducing mock-only license bypasses or page-local commercial fallbacks.
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
import { apiFetch } from '@/utils/apiClient';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
|
||||
|
||||
export function DemoBanner() {
|
||||
const [isDemoMode, setIsDemoMode] = createSignal(false);
|
||||
const [dismissed, setDismissed] = createSignal(false);
|
||||
|
||||
onMount(async () => {
|
||||
// Check if we're in demo mode by trying a test request
|
||||
try {
|
||||
const response = await apiFetch('/api/health');
|
||||
const demoHeader = response.headers.get('X-Demo-Mode');
|
||||
if (demoHeader === 'true') {
|
||||
setIsDemoMode(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: banner remains hidden when demo detection cannot be verified.
|
||||
logger.debug('[DemoBanner] Failed to check demo mode', error);
|
||||
void ensureDemoModeResolved();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (sessionStorage.getItem('demoBannerDismissed') === 'true') {
|
||||
setDismissed(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -26,15 +20,8 @@ export function DemoBanner() {
|
|||
sessionStorage.setItem('demoBannerDismissed', 'true');
|
||||
};
|
||||
|
||||
// Check if already dismissed this session
|
||||
onMount(() => {
|
||||
if (sessionStorage.getItem('demoBannerDismissed') === 'true') {
|
||||
setDismissed(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={isDemoMode() && !dismissed()}>
|
||||
<Show when={demoModeEnabled() && !dismissed()}>
|
||||
<div class="bg-blue-50 dark:bg-blue-900 border-b border-blue-200 dark:border-blue-800 px-3 py-2">
|
||||
<div class="container mx-auto flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,14 @@ describe('Settings architecture guardrails', () => {
|
|||
expect(SETTINGS_HEADER_META['organization-billing'].description).toContain('plan limits');
|
||||
});
|
||||
|
||||
it('keeps demo-mode billing visibility on the shared settings shell owner', () => {
|
||||
expect(settingsNavigationModelSource).toContain('hideInDemoMode?: boolean;');
|
||||
expect(settingsNavVisibilitySource).toContain('item.hideInDemoMode && context.demoModeEnabled');
|
||||
expect(getSettingsNavItem('system-billing')?.hideInDemoMode).toBe(true);
|
||||
expect(getSettingsNavItem('organization-billing')?.hideInDemoMode).toBe(true);
|
||||
expect(getSettingsNavItem('organization-billing-admin')?.hideInDemoMode).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps relay shell copy on the shared relay presentation owner', () => {
|
||||
expect(settingsHeaderMetaSource).toContain('RELAY_SETTINGS_DESCRIPTION');
|
||||
expect(relaySettingsPanelSource).toContain('description={RELAY_SETTINGS_DESCRIPTION}');
|
||||
|
|
|
|||
|
|
@ -96,6 +96,26 @@ describe('settingsNavigation integration scaffold', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides billing tabs in demo mode', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem('system-billing', {
|
||||
hasFeature: hasFeatures([]),
|
||||
licenseLoaded: () => true,
|
||||
demoModeEnabled: true,
|
||||
hostedModeEnabled: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldHideSettingsNavItem('organization-billing', {
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
licenseLoaded: () => true,
|
||||
demoModeEnabled: true,
|
||||
hostedModeEnabled: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides tabs when the backend denies the required capability', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem('api', {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
|||
iconProps: { strokeWidth: 2 },
|
||||
features: ['multi_tenant'],
|
||||
hideWhenUnavailable: true,
|
||||
hideInDemoMode: true,
|
||||
},
|
||||
{
|
||||
id: 'organization-billing-admin',
|
||||
|
|
@ -81,6 +82,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
|||
features: ['multi_tenant'],
|
||||
hideWhenUnavailable: true,
|
||||
hostedOnly: true,
|
||||
hideInDemoMode: true,
|
||||
requiredCapability: 'billingAdmin',
|
||||
},
|
||||
],
|
||||
|
|
@ -127,6 +129,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
|
|||
id: 'system-billing',
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle,
|
||||
icon: PulseLogoIcon,
|
||||
hideInDemoMode: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type { SettingsTab } from './settingsNavigationModel';
|
|||
export interface SettingsNavVisibilityContext {
|
||||
hasFeature: (feature: string) => boolean;
|
||||
licenseLoaded: () => boolean;
|
||||
demoModeEnabled?: boolean;
|
||||
demoModeResolved?: boolean;
|
||||
hostedModeEnabled?: boolean;
|
||||
settingsCapabilities?: Partial<SecurityStatusSettingsCapabilities> | null;
|
||||
settingsCapabilitiesResolved?: boolean;
|
||||
|
|
@ -30,6 +32,10 @@ export function shouldHideSettingsNavItem(
|
|||
return true;
|
||||
}
|
||||
|
||||
if (item.hideInDemoMode && context.demoModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
item.requiredCapability &&
|
||||
context.settingsCapabilitiesResolved &&
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface SettingsNavItem {
|
|||
locked?: boolean;
|
||||
hideWhenUnavailable?: boolean;
|
||||
hostedOnly?: boolean;
|
||||
hideInDemoMode?: boolean;
|
||||
requiredCapability?: keyof SecurityStatusSettingsCapabilities;
|
||||
badge?: string;
|
||||
features?: string[];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Accessor, createEffect, createMemo, createSignal } from 'solid-js';
|
||||
import { demoModeEnabled, demoModeResolved } from '@/stores/demoMode';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { hasFeature, isHostedModeEnabled, licenseLoaded } from '@/stores/license';
|
||||
|
|
@ -35,6 +36,8 @@ export function useSettingsAccess({
|
|||
!shouldHideSettingsNavItem(item.id, {
|
||||
hasFeature,
|
||||
licenseLoaded,
|
||||
demoModeEnabled: demoModeEnabled(),
|
||||
demoModeResolved: demoModeResolved(),
|
||||
hostedModeEnabled,
|
||||
settingsCapabilities,
|
||||
settingsCapabilitiesResolved,
|
||||
|
|
@ -70,9 +73,11 @@ export function useSettingsAccess({
|
|||
const current = activeTab();
|
||||
const requiresFeatureResolution = Boolean(tabFeatureRequirements[current]?.length);
|
||||
const requiresCapabilityResolution = Boolean(getSettingsNavItem(current)?.requiredCapability);
|
||||
const requiresDemoModeResolution = Boolean(getSettingsNavItem(current)?.hideInDemoMode);
|
||||
if (
|
||||
(requiresFeatureResolution && !licenseLoaded()) ||
|
||||
(requiresCapabilityResolution && securityStatusLoading())
|
||||
(requiresCapabilityResolution && securityStatusLoading()) ||
|
||||
(requiresDemoModeResolution && !demoModeResolved())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,47 +5,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
/* Mocks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const apiFetchMock = vi.hoisted(() => vi.fn());
|
||||
const demoModeEnabledMock = vi.hoisted(() => vi.fn());
|
||||
const ensureDemoModeResolvedMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/utils/apiClient', () => ({
|
||||
apiFetch: apiFetchMock,
|
||||
vi.mock('@/stores/demoMode', () => ({
|
||||
demoModeEnabled: () => demoModeEnabledMock(),
|
||||
ensureDemoModeResolved: (...args: unknown[]) => ensureDemoModeResolvedMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Build a minimal Response-like object with the given demo header value. */
|
||||
function fakeResponse(demoHeader: string | null) {
|
||||
const headers = new Headers();
|
||||
if (demoHeader !== null) {
|
||||
headers.set('X-Demo-Mode', demoHeader);
|
||||
}
|
||||
return { ok: true, headers };
|
||||
}
|
||||
|
||||
/** Create a deferred promise whose resolution the test controls. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('DemoBanner', () => {
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
demoModeEnabledMock.mockReset();
|
||||
ensureDemoModeResolvedMock.mockReset();
|
||||
demoModeEnabledMock.mockReturnValue(false);
|
||||
ensureDemoModeResolvedMock.mockResolvedValue(false);
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
|
|
@ -58,73 +39,28 @@ describe('DemoBanner', () => {
|
|||
|
||||
/* ---------- Visibility ---------- */
|
||||
|
||||
it('shows the banner when X-Demo-Mode header is "true"', async () => {
|
||||
apiFetchMock.mockResolvedValue(fakeResponse('true'));
|
||||
it('shows the banner when demo mode is enabled', async () => {
|
||||
demoModeEnabledMock.mockReturnValue(true);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden when X-Demo-Mode header is absent', async () => {
|
||||
const d = deferred();
|
||||
apiFetchMock.mockReturnValue(d.promise);
|
||||
|
||||
it('stays hidden when demo mode is disabled', async () => {
|
||||
await renderBanner();
|
||||
|
||||
// Banner must be hidden while request is in flight.
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
|
||||
// Resolve with no demo header and verify banner stays hidden.
|
||||
d.resolve(fakeResponse(null));
|
||||
await waitFor(() => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden when X-Demo-Mode header is "false"', async () => {
|
||||
const d = deferred();
|
||||
apiFetchMock.mockReturnValue(d.promise);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
|
||||
d.resolve(fakeResponse('false'));
|
||||
await waitFor(() => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden when the health check request fails', async () => {
|
||||
const d = deferred();
|
||||
apiFetchMock.mockReturnValue(d.promise);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
|
||||
d.reject(new Error('network error'));
|
||||
await waitFor(() => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* ---------- Dismiss ---------- */
|
||||
|
||||
it('hides the banner when the dismiss button is clicked', async () => {
|
||||
apiFetchMock.mockResolvedValue(fakeResponse('true'));
|
||||
demoModeEnabledMock.mockReturnValue(true);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
|
||||
const dismissBtn = screen.getByTitle('Dismiss');
|
||||
fireEvent.click(dismissBtn);
|
||||
|
|
@ -137,13 +73,11 @@ describe('DemoBanner', () => {
|
|||
});
|
||||
|
||||
it('persists dismissal to sessionStorage', async () => {
|
||||
apiFetchMock.mockResolvedValue(fakeResponse('true'));
|
||||
demoModeEnabledMock.mockReturnValue(true);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Demo instance with mock data (read-only)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Dismiss'));
|
||||
|
||||
|
|
@ -152,32 +86,22 @@ describe('DemoBanner', () => {
|
|||
|
||||
it('stays hidden when sessionStorage already has dismissal flag', async () => {
|
||||
sessionStorage.setItem('demoBannerDismissed', 'true');
|
||||
|
||||
const d = deferred();
|
||||
apiFetchMock.mockReturnValue(d.promise);
|
||||
demoModeEnabledMock.mockReturnValue(true);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
|
||||
// Even when the API confirms demo mode, prior dismissal keeps the banner hidden.
|
||||
d.resolve(fakeResponse('true'));
|
||||
await waitFor(() => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText('Demo instance with mock data (read-only)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* ---------- API call ---------- */
|
||||
/* ---------- Demo-mode resolve ---------- */
|
||||
|
||||
it('calls /api/health exactly once on mount', async () => {
|
||||
apiFetchMock.mockResolvedValue(fakeResponse(null));
|
||||
it('resolves demo mode exactly once on mount', async () => {
|
||||
ensureDemoModeResolvedMock.mockResolvedValue(false);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(ensureDemoModeResolvedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(apiFetchMock).toHaveBeenCalledWith('/api/health');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -469,8 +469,11 @@ describe('shared primitive guardrails', () => {
|
|||
expect(trialBannerStateSource).toContain('createMemo');
|
||||
expect(trialBannerStateSource).toContain('loadLicenseStatus');
|
||||
expect(trialBannerStateSource).toContain('licenseStatus');
|
||||
expect(trialBannerStateSource).toContain('demoModeEnabled');
|
||||
expect(trialBannerStateSource).toContain('ensureDemoModeResolved');
|
||||
expect(trialBannerStateSource).toContain('getUpgradeActionDestination');
|
||||
expect(trialBannerStateSource).toContain('snoozeUpsell');
|
||||
expect(trialBannerStateSource).not.toContain("fetch('/api/health'");
|
||||
|
||||
expect(trialBannerModelSource).toContain('TRIAL_BANNER_SNOOZE_KEY');
|
||||
expect(trialBannerModelSource).toContain('normalizeTrialBannerDaysRemaining');
|
||||
|
|
@ -661,6 +664,8 @@ describe('shared primitive guardrails', () => {
|
|||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadLicenseStatus');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('demoModeEnabled');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('ensureDemoModeResolved');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('legacyConnections');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain(
|
||||
|
|
@ -670,6 +675,7 @@ describe('shared primitive guardrails', () => {
|
|||
'SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID',
|
||||
);
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('handleUpgradeClick');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).not.toContain("fetch('/api/health'");
|
||||
|
||||
expect(monitoredSystemLimitWarningBannerModelSource).toContain(
|
||||
'getMonitoredSystemMigrationMessage',
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const mockLegacyConnections = vi.hoisted(() =>
|
|||
const mockTrackUpgradeMetricEvent = vi.hoisted(() => vi.fn());
|
||||
const mockTrackUpgradeClicked = vi.hoisted(() => vi.fn());
|
||||
const mockLoadLicenseStatus = vi.hoisted(() => vi.fn());
|
||||
const mockDemoModeEnabled = vi.hoisted(() => vi.fn(() => false));
|
||||
const mockEnsureDemoModeResolved = vi.hoisted(() => vi.fn());
|
||||
const mockGetUpgradeActionDestination = vi.hoisted(() => vi.fn());
|
||||
const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
@ -44,6 +46,11 @@ vi.mock('@/stores/license', () => ({
|
|||
loadLicenseStatus: (...args: unknown[]) => mockLoadLicenseStatus(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/demoMode', () => ({
|
||||
demoModeEnabled: () => mockDemoModeEnabled(),
|
||||
ensureDemoModeResolved: (...args: unknown[]) => mockEnsureDemoModeResolved(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/upgradeMetrics', () => ({
|
||||
UPGRADE_METRIC_EVENTS: {
|
||||
LIMIT_WARNING_SHOWN: 'limit_warning_shown',
|
||||
|
|
@ -63,6 +70,10 @@ describe('MonitoredSystemLimitWarningBanner', () => {
|
|||
docker_hosts: 0,
|
||||
kubernetes_clusters: 0,
|
||||
});
|
||||
mockDemoModeEnabled.mockReset();
|
||||
mockDemoModeEnabled.mockReturnValue(false);
|
||||
mockEnsureDemoModeResolved.mockReset();
|
||||
mockEnsureDemoModeResolved.mockResolvedValue(false);
|
||||
mockLoadLicenseStatus.mockReset();
|
||||
mockLoadLicenseStatus.mockResolvedValue(undefined);
|
||||
mockGetUpgradeActionDestination.mockReset();
|
||||
|
|
@ -98,6 +109,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
|
|||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createEffect');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('createMemo');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('loadLicenseStatus');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('ensureDemoModeResolved');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('trackUpgradeMetricEvent');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('legacyConnections');
|
||||
expect(monitoredSystemLimitWarningBannerStateSource).toContain('handleUpgradeClick');
|
||||
|
|
@ -130,6 +142,7 @@ describe('MonitoredSystemLimitWarningBanner', () => {
|
|||
));
|
||||
|
||||
expect(mockLoadLicenseStatus).toHaveBeenCalled();
|
||||
expect(mockEnsureDemoModeResolved).toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Monitored systems:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -210,4 +223,24 @@ describe('MonitoredSystemLimitWarningBanner', () => {
|
|||
'/settings/system/billing#pulse-pro-plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays hidden in demo mode even when usage is urgent', async () => {
|
||||
mockDemoModeEnabled.mockReturnValue(true);
|
||||
mockGetLimit.mockReturnValue({
|
||||
key: 'max_monitored_systems',
|
||||
limit: 6,
|
||||
current: 5,
|
||||
state: 'warning',
|
||||
});
|
||||
|
||||
const mod = await import('../MonitoredSystemLimitWarningBanner');
|
||||
render(() => (
|
||||
<Router>
|
||||
<Route path="/" component={mod.MonitoredSystemLimitWarningBanner} />
|
||||
</Router>
|
||||
));
|
||||
|
||||
expect(screen.queryByText('Monitored systems: 5/6')).not.toBeInTheDocument();
|
||||
expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { TRIAL_BANNER_SNOOZE_KEY } from '@/components/shared/trialBannerModel';
|
|||
import { getPublicPricingUrl } from '@/utils/pricingHandoff';
|
||||
|
||||
const {
|
||||
demoModeEnabledMock,
|
||||
ensureDemoModeResolvedMock,
|
||||
getUpgradeActionDestinationMock,
|
||||
getUpgradeActionUrlOrFallbackMock,
|
||||
licenseStatusMock,
|
||||
|
|
@ -16,6 +18,8 @@ const {
|
|||
snoozeUpsellMock,
|
||||
} =
|
||||
vi.hoisted(() => ({
|
||||
demoModeEnabledMock: vi.fn(),
|
||||
ensureDemoModeResolvedMock: vi.fn(),
|
||||
getUpgradeActionDestinationMock: vi.fn(),
|
||||
getUpgradeActionUrlOrFallbackMock: vi.fn(),
|
||||
licenseStatusMock: vi.fn(),
|
||||
|
|
@ -31,6 +35,11 @@ vi.mock('@/stores/license', () => ({
|
|||
loadLicenseStatus: (...args: unknown[]) => loadLicenseStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/demoMode', () => ({
|
||||
demoModeEnabled: () => demoModeEnabledMock(),
|
||||
ensureDemoModeResolved: (...args: unknown[]) => ensureDemoModeResolvedMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/snooze', () => ({
|
||||
isUpsellSnoozed: (...args: unknown[]) => isUpsellSnoozedMock(...args),
|
||||
snoozeUpsell: (...args: unknown[]) => snoozeUpsellMock(...args),
|
||||
|
|
@ -39,6 +48,8 @@ vi.mock('@/utils/snooze', () => ({
|
|||
describe('TrialBanner', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
demoModeEnabledMock.mockReset();
|
||||
ensureDemoModeResolvedMock.mockReset();
|
||||
getUpgradeActionDestinationMock.mockReset();
|
||||
getUpgradeActionUrlOrFallbackMock.mockReset();
|
||||
licenseStatusMock.mockReset();
|
||||
|
|
@ -49,6 +60,8 @@ describe('TrialBanner', () => {
|
|||
href: getPublicPricingUrl('trial_banner'),
|
||||
external: true,
|
||||
});
|
||||
demoModeEnabledMock.mockReturnValue(false);
|
||||
ensureDemoModeResolvedMock.mockResolvedValue(false);
|
||||
getUpgradeActionUrlOrFallbackMock.mockReturnValue(getPublicPricingUrl('trial_banner'));
|
||||
loadLicenseStatusMock.mockResolvedValue(undefined);
|
||||
isUpsellSnoozedMock.mockReturnValue(false);
|
||||
|
|
@ -71,6 +84,7 @@ describe('TrialBanner', () => {
|
|||
expect(trialBannerStateSource).toContain('createSignal');
|
||||
expect(trialBannerStateSource).toContain('createMemo');
|
||||
expect(trialBannerStateSource).toContain('loadLicenseStatus');
|
||||
expect(trialBannerStateSource).toContain('ensureDemoModeResolved');
|
||||
expect(trialBannerStateSource).toContain('licenseStatus');
|
||||
expect(trialBannerStateSource).toContain('getUpgradeActionDestination');
|
||||
expect(trialBannerStateSource).toContain('snoozeUpsell');
|
||||
|
|
@ -92,6 +106,7 @@ describe('TrialBanner', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(loadLicenseStatusMock).toHaveBeenCalled();
|
||||
expect(ensureDemoModeResolvedMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro Trial:')).toBeInTheDocument();
|
||||
|
|
@ -113,6 +128,18 @@ describe('TrialBanner', () => {
|
|||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden in demo mode even when the workspace is on trial', () => {
|
||||
demoModeEnabledMock.mockReturnValue(true);
|
||||
licenseStatusMock.mockReturnValue({
|
||||
subscription_state: 'trial',
|
||||
trial_days_remaining: 2,
|
||||
});
|
||||
|
||||
render(() => <TrialBanner />);
|
||||
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('snoozes and hides the action row', async () => {
|
||||
licenseStatusMock.mockReturnValue({
|
||||
subscription_state: 'trial',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, createMemo, onMount } from 'solid-js';
|
||||
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
|
||||
import {
|
||||
entitlements,
|
||||
getLimit,
|
||||
|
|
@ -33,11 +34,14 @@ import {
|
|||
export function useMonitoredSystemLimitWarningBannerState() {
|
||||
onMount(() => {
|
||||
void loadLicenseStatus();
|
||||
void ensureDemoModeResolved();
|
||||
});
|
||||
|
||||
const monitoredSystemLimit = createMemo(() => getLimit(MONITORED_SYSTEM_LIMIT_KEY));
|
||||
const isUrgent = createMemo(() => isMonitoredSystemLimitUrgent(monitoredSystemLimit()));
|
||||
const showBanner = createMemo(() => shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit()));
|
||||
const showBanner = createMemo(
|
||||
() => !demoModeEnabled() && shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit()),
|
||||
);
|
||||
const migrationGap = createMemo(() => hasMigrationGap());
|
||||
const migrationCounts = createMemo(() => legacyConnections());
|
||||
const monitoredSystemSummary = createMemo(() =>
|
||||
|
|
@ -69,8 +73,9 @@ export function useMonitoredSystemLimitWarningBannerState() {
|
|||
let wasUrgent = false;
|
||||
createEffect(() => {
|
||||
const urgent = isUrgent();
|
||||
const visible = showBanner();
|
||||
const limit = monitoredSystemLimit();
|
||||
if (urgent && !wasUrgent && limit) {
|
||||
if (visible && urgent && !wasUrgent && limit) {
|
||||
trackUpgradeMetricEvent({
|
||||
type: UPGRADE_METRIC_EVENTS.LIMIT_WARNING_SHOWN,
|
||||
surface: 'monitored_system_limit_banner',
|
||||
|
|
@ -79,7 +84,7 @@ export function useMonitoredSystemLimitWarningBannerState() {
|
|||
limit_value: limit.limit,
|
||||
});
|
||||
}
|
||||
wasUrgent = urgent;
|
||||
wasUrgent = visible && urgent;
|
||||
});
|
||||
|
||||
const handleInstallCollectorsClick = () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createMemo, createSignal, onMount } from 'solid-js';
|
||||
import { demoModeEnabled, ensureDemoModeResolved } from '@/stores/demoMode';
|
||||
import { getUpgradeActionDestination, licenseStatus, loadLicenseStatus } from '@/stores/license';
|
||||
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
|
||||
import {
|
||||
|
|
@ -13,9 +14,12 @@ export function useTrialBannerState() {
|
|||
|
||||
onMount(() => {
|
||||
void loadLicenseStatus();
|
||||
void ensureDemoModeResolved();
|
||||
});
|
||||
|
||||
const isTrial = createMemo(() => licenseStatus()?.subscription_state === 'trial');
|
||||
const isTrial = createMemo(
|
||||
() => !demoModeEnabled() && licenseStatus()?.subscription_state === 'trial',
|
||||
);
|
||||
const daysRemaining = createMemo(() =>
|
||||
normalizeTrialBannerDaysRemaining(licenseStatus()?.trial_days_remaining),
|
||||
);
|
||||
|
|
@ -33,7 +37,7 @@ export function useTrialBannerState() {
|
|||
daysRemaining,
|
||||
handleSnooze,
|
||||
isTrial,
|
||||
showActions: () => !snoozed(),
|
||||
showActions: () => !demoModeEnabled() && !snoozed(),
|
||||
toneClass,
|
||||
upgradeDestination,
|
||||
};
|
||||
|
|
|
|||
52
frontend-modern/src/stores/demoMode.ts
Normal file
52
frontend-modern/src/stores/demoMode.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
const [demoModeEnabled, setDemoModeEnabled] = createSignal(false);
|
||||
const [demoModeResolved, setDemoModeResolved] = createSignal(false);
|
||||
|
||||
let pendingDemoModeCheck: Promise<boolean> | null = null;
|
||||
|
||||
function applyDemoModeHeaderValue(value: string | null): boolean {
|
||||
const enabled = value === 'true';
|
||||
setDemoModeEnabled(enabled);
|
||||
setDemoModeResolved(true);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function syncDemoModeFromResponse(response: Response): boolean {
|
||||
return applyDemoModeHeaderValue(response.headers.get('X-Demo-Mode'));
|
||||
}
|
||||
|
||||
export async function ensureDemoModeResolved(force = false): Promise<boolean> {
|
||||
if (demoModeResolved() && !force) {
|
||||
return demoModeEnabled();
|
||||
}
|
||||
if (pendingDemoModeCheck && !force) {
|
||||
return pendingDemoModeCheck;
|
||||
}
|
||||
|
||||
pendingDemoModeCheck = fetch('/api/health', {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
})
|
||||
.then((response) => syncDemoModeFromResponse(response))
|
||||
.catch((error) => {
|
||||
logger.debug('[demoModeStore] Failed to resolve demo mode from /api/health', error);
|
||||
if (!demoModeResolved()) {
|
||||
setDemoModeResolved(true);
|
||||
}
|
||||
return demoModeEnabled();
|
||||
})
|
||||
.finally(() => {
|
||||
pendingDemoModeCheck = null;
|
||||
});
|
||||
|
||||
return pendingDemoModeCheck;
|
||||
}
|
||||
|
||||
export { demoModeEnabled, demoModeResolved };
|
||||
|
|
@ -67,6 +67,25 @@ describe('apiClient org context', () => {
|
|||
expect(headers['X-Pulse-Org-ID']).toBe('tenant-ledger');
|
||||
});
|
||||
|
||||
it('tracks demo mode from shared API response headers', async () => {
|
||||
vi.resetModules();
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'X-Demo-Mode': 'true' },
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const apiClientModule = await import('@/utils/apiClient');
|
||||
const demoModeModule = await import('@/stores/demoMode');
|
||||
|
||||
await apiClientModule.apiFetch('/api/state');
|
||||
|
||||
expect(demoModeModule.demoModeResolved()).toBe(true);
|
||||
expect(demoModeModule.demoModeEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses default org context when skipOrgContext is enabled', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('[]', { status: 200 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// This replaces the three separate auth utilities (api.ts, auth.ts, authInterceptor.ts)
|
||||
|
||||
import { logger } from '@/utils/logger';
|
||||
import { syncDemoModeFromResponse } from '@/stores/demoMode';
|
||||
import { STORAGE_KEYS } from '@/utils/localStorage';
|
||||
|
||||
const AUTH_STORAGE_KEY = STORAGE_KEYS.AUTH;
|
||||
|
|
@ -626,7 +627,12 @@ class ApiClient {
|
|||
credentials: 'include', // Important for session cookies
|
||||
};
|
||||
|
||||
const response = await fetch(url, finalOptions);
|
||||
const observeDemoMode = (response: Response): Response => {
|
||||
syncDemoModeFromResponse(response);
|
||||
return response;
|
||||
};
|
||||
|
||||
const response = observeDemoMode(await fetch(url, finalOptions));
|
||||
|
||||
// Handle stale/invalid org context by clearing it and retrying once against default org.
|
||||
if (
|
||||
|
|
@ -649,11 +655,13 @@ class ApiClient {
|
|||
this.setOrgID(null);
|
||||
const retryHeaders: Record<string, string> = { ...finalHeaders };
|
||||
delete retryHeaders[ORG_HEADER_NAME];
|
||||
return fetch(url, {
|
||||
return observeDemoMode(
|
||||
await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: retryHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -694,11 +702,11 @@ class ApiClient {
|
|||
this.csrfToken = refreshedToken;
|
||||
logger.debug(`[apiClient] Retrying ${method} ${url} with refreshed CSRF token`);
|
||||
finalHeaders['X-CSRF-Token'] = refreshedToken;
|
||||
const retryResponse = await fetch(url, {
|
||||
const retryResponse = observeDemoMode(await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: finalHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
}));
|
||||
return retryResponse;
|
||||
}
|
||||
}
|
||||
|
|
@ -740,11 +748,11 @@ class ApiClient {
|
|||
throw err;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
return observeDemoMode(await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers: finalHeaders,
|
||||
credentials: 'include',
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -294,6 +294,40 @@ func TestBillingStateHostedModeGate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBillingStateDemoModeGate(t *testing.T) {
|
||||
router, _ := newBillingStateTestRouter(t, true)
|
||||
router.config.DemoMode = true
|
||||
router.mux = http.NewServeMux()
|
||||
router.registerHostedRoutes(nil, nil, nil)
|
||||
|
||||
testCases := []struct {
|
||||
method string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
method: http.MethodGet,
|
||||
body: "",
|
||||
},
|
||||
{
|
||||
method: http.MethodPut,
|
||||
body: `{
|
||||
"capabilities":["feature_x"],
|
||||
"limits":{"max_monitored_systems":10},
|
||||
"meters_enabled":["api_requests"],
|
||||
"plan_version":"pro-v1",
|
||||
"subscription_state":"active"
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
rec := doBillingStateRequest(router, tc.method, "/api/admin/orgs/acme/billing-state", tc.body)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when demo mode is enabled for %s, got %d: %s", tc.method, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newBillingStateTestRouter(t *testing.T, hostedMode bool) (*Router, string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
|
|||
|
|
@ -4811,6 +4811,45 @@ func TestContract_HostedBillingStateFallbackJSONSnapshot(t *testing.T) {
|
|||
assertJSONSnapshot(t, rec.Body.Bytes(), want)
|
||||
}
|
||||
|
||||
func TestContract_DemoModeCommercialReadSurfaceReturnsNotFound(t *testing.T) {
|
||||
t.Run("license status", func(t *testing.T) {
|
||||
handlers := NewLicenseHandlers(nil, false, &config.Config{DemoMode: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handlers.HandleLicenseStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("monitored system ledger", func(t *testing.T) {
|
||||
router := &Router{config: &config.Config{DemoMode: true}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/monitored-system-ledger", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.handleMonitoredSystemLedger(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hosted billing state", func(t *testing.T) {
|
||||
handlers := NewBillingStateHandlers(config.NewFileBillingStore(t.TempDir()), true, true)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/orgs/t-tenant/billing-state", nil)
|
||||
req.SetPathValue("id", "t-tenant")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handlers.HandleGetBillingState(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContract_HandoffExchangeJSONSnapshot(t *testing.T) {
|
||||
key := []byte("test-handoff-key")
|
||||
configDir := t.TempDir()
|
||||
|
|
|
|||
14
internal/api/demo_mode_commercial.go
Normal file
14
internal/api/demo_mode_commercial.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
// hideCommercialReadSurfaceInDemo returns a generic 404 for demo-only public
|
||||
// runtimes so billing and license detail endpoints are not surfaced to users.
|
||||
func hideCommercialReadSurfaceInDemo(w http.ResponseWriter, r *http.Request, demoMode bool) bool {
|
||||
if !demoMode {
|
||||
return false
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
return true
|
||||
}
|
||||
|
|
@ -187,6 +187,20 @@ func TestHandleLicenseStatus_NoLicense(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleLicenseStatus_DemoModeReturnsNotFound(t *testing.T) {
|
||||
handler := createTestHandler(t)
|
||||
handler.SetConfig(&config.Config{DemoMode: true})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleLicenseStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLicenseStatus_WithActiveLicense(t *testing.T) {
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "true")
|
||||
|
||||
|
|
|
|||
|
|
@ -918,6 +918,9 @@ func (h *LicenseHandlers) HandleLicenseStatus(w http.ResponseWriter, r *http.Req
|
|||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if hideCommercialReadSurfaceInDemo(w, r, h != nil && h.cfg != nil && h.cfg.DemoMode) {
|
||||
return
|
||||
}
|
||||
|
||||
service, _, err := h.getTenantComponents(r.Context())
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ func (e MonitoredSystemLedgerEntry) NormalizeCollections() MonitoredSystemLedger
|
|||
}
|
||||
|
||||
func (r *Router) handleMonitoredSystemLedger(w http.ResponseWriter, req *http.Request) {
|
||||
if hideCommercialReadSurfaceInDemo(w, req, r != nil && r.config != nil && r.config.DemoMode) {
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(req.Context())
|
||||
|
||||
// Get canonical monitored systems from the unified ReadState surface.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
|
|
@ -326,3 +327,15 @@ func TestHandleMonitoredSystemLedgerHTTP(t *testing.T) {
|
|||
t.Errorf("expected explanation summary, got %+v", decoded.Systems[0].Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMonitoredSystemLedger_DemoModeReturnsNotFound(t *testing.T) {
|
||||
router := &Router{config: &config.Config{DemoMode: true}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/license/monitored-system-ledger", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.handleMonitoredSystemLedger(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ func (r *Router) registerHostedRoutes(hostedSignupHandlers *HostedSignupHandlers
|
|||
routerConfig = &config.Config{}
|
||||
}
|
||||
|
||||
billingHandlers := NewBillingStateHandlers(config.NewFileBillingStore(routerConfig.DataPath), r.hostedMode)
|
||||
billingHandlers := NewBillingStateHandlers(
|
||||
config.NewFileBillingStore(routerConfig.DataPath),
|
||||
r.hostedMode,
|
||||
routerConfig.DemoMode,
|
||||
)
|
||||
lifecycleHandlers := NewOrgLifecycleHandlers(r.multiTenant, r.hostedMode)
|
||||
hostedOrgAdminHandlers := NewHostedOrgAdminHandlers(r.multiTenant, r.hostedMode)
|
||||
r.mux.HandleFunc(
|
||||
|
|
|
|||
|
|
@ -11,12 +11,19 @@ import (
|
|||
|
||||
type BillingStateHandlers struct {
|
||||
store *config.FileBillingStore
|
||||
demoMode bool
|
||||
hostedMode bool
|
||||
}
|
||||
|
||||
func NewBillingStateHandlers(store *config.FileBillingStore, hostedMode bool) *BillingStateHandlers {
|
||||
func NewBillingStateHandlers(store *config.FileBillingStore, hostedMode bool, demoModes ...bool) *BillingStateHandlers {
|
||||
demoMode := false
|
||||
if len(demoModes) > 0 {
|
||||
demoMode = demoModes[0]
|
||||
}
|
||||
|
||||
return &BillingStateHandlers{
|
||||
store: store,
|
||||
demoMode: demoMode,
|
||||
hostedMode: hostedMode,
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +37,9 @@ func (h *BillingStateHandlers) HandleGetBillingState(w http.ResponseWriter, r *h
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if hideCommercialReadSurfaceInDemo(w, r, h.demoMode) {
|
||||
return
|
||||
}
|
||||
if h.store == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "billing_store_unavailable", "Billing persistence is not configured", nil)
|
||||
return
|
||||
|
|
@ -83,6 +93,9 @@ func (h *BillingStateHandlers) HandlePutBillingState(w http.ResponseWriter, r *h
|
|||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if hideCommercialReadSurfaceInDemo(w, r, h.demoMode) {
|
||||
return
|
||||
}
|
||||
if h.store == nil {
|
||||
writeErrorResponse(w, http.StatusServiceUnavailable, "billing_store_unavailable", "Billing persistence is not configured", nil)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3709,8 +3709,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 110,
|
||||
"heading_line": 78,
|
||||
"line": 111,
|
||||
"heading_line": 79,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue