From 149834cd802915fc98f16d96a04d5a2abaaad487 Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Thu, 18 Jun 2026 07:26:27 -0400 Subject: [PATCH] Make portfolio demo actions safe --- app/api/stripe/checkout/route.ts | 5 +++ app/api/stripe/portal/route.ts | 7 ++++ app/app/billing/page.tsx | 29 +++++++++++--- app/app/leads/[leadId]/page.tsx | 16 +++++++- app/app/leads/actions.ts | 5 +++ app/app/settings/actions.ts | 5 +++ app/app/settings/page.tsx | 9 ++++- components/home-dashboard.tsx | 21 ++++++++-- lib/portfolio-demo.ts | 2 + tests/portfolio-demo-actions.test.ts | 57 ++++++++++++++++++++++++++++ 10 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 tests/portfolio-demo-actions.test.ts diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index 457a09a..8cf3084 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -5,6 +5,7 @@ import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; @@ -24,6 +25,10 @@ export async function POST(request: Request) { if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) { return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })); } + if (isPortfolioDemoMode()) { + return withCorrelation(errorRedirect(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)); + } + const { userId } = await auth(); if (!userId) { return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts index 044f14f..3142ef7 100644 --- a/app/api/stripe/portal/route.ts +++ b/app/api/stripe/portal/route.ts @@ -5,6 +5,7 @@ import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; @@ -19,6 +20,12 @@ export async function POST(request: Request) { if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) { return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 })); } + if (isPortfolioDemoMode()) { + return withCorrelation( + NextResponse.redirect(absoluteUrl(`/app/billing?error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`), { status: 303 }) + ); + } + const { userId } = await auth(); if (!userId) { return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); diff --git a/app/app/billing/page.tsx b/app/app/billing/page.tsx index 5eea9ea..47f382f 100644 --- a/app/app/billing/page.tsx +++ b/app/app/billing/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { requireBusiness } from '@/lib/auth'; import { getBillingUsageSnapshotForBusiness } from '@/lib/business-access'; import { db } from '@/lib/db'; -import { getPortfolioDemoBlockedCount, isPortfolioDemoMode } from '@/lib/portfolio-demo'; +import { getPortfolioDemoBlockedCount, isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { getBusinessBillingAccessState } from '@/lib/subscription'; import { getBillingDisplayLabel } from '@/lib/system-status'; import { getStripe } from '@/lib/stripe'; @@ -212,6 +212,7 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec {error ?
{error}
: null} + {demoMode ?
{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}
: null} {requestedPlan ? (
Selected plan: {requestedPlan === 'starter' ? 'Starter' : 'Growth'}. Continue with the checkout card below. @@ -250,7 +251,9 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
{business.stripeCustomerId ? (
- +
) : ( @@ -332,7 +335,12 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
-
@@ -351,7 +359,12 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec
-
@@ -372,7 +385,13 @@ export default async function BillingPage({ searchParams }: { searchParams?: Rec {business.stripeCustomerId ? (
-
diff --git a/app/app/leads/[leadId]/page.tsx b/app/app/leads/[leadId]/page.tsx index 8dd94d8..8337848 100644 --- a/app/app/leads/[leadId]/page.tsx +++ b/app/app/leads/[leadId]/page.tsx @@ -21,7 +21,7 @@ import { smsStateLabels, } from '@/lib/lead-presenters'; import { formatPhoneForDisplay } from '@/lib/phone'; -import { getPortfolioDemoLeadDetail, isPortfolioDemoMode } from '@/lib/portfolio-demo'; +import { getPortfolioDemoLeadDetail, isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; function resolveSafeReturnPath(value: string | null | undefined) { if (!value) return '/app/leads'; @@ -55,6 +55,7 @@ function LeadStatusActionForm({ successRedirectTo, label, variant, + disabled = false, }: { leadId: string; status: 'CONTACTED' | 'BOOKED' | 'LOST'; @@ -62,7 +63,16 @@ function LeadStatusActionForm({ successRedirectTo: string; label: string; variant?: 'default' | 'outline' | 'destructive'; + disabled?: boolean; }) { + if (disabled) { + return ( + + ); + } + return (
@@ -126,6 +136,7 @@ export default async function LeadDetailPage({ {error ?
{error}
: null} {saved ?
Lead updated.
: null} + {demoMode ?
{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}
: null} {messageIssues.length > 0 ? (
This lead had an SMS delivery issue. Manual follow-up is recommended. @@ -168,6 +179,7 @@ export default async function LeadDetailPage({ successRedirectTo={successRedirectTo} label="Mark contacted" variant="outline" + disabled={demoMode} />
diff --git a/app/app/leads/actions.ts b/app/app/leads/actions.ts index 4622286..75d0463 100644 --- a/app/app/leads/actions.ts +++ b/app/app/leads/actions.ts @@ -6,6 +6,7 @@ import { redirect } from 'next/navigation'; import { requireBusiness } from '@/lib/auth'; import { updateLeadStatusForBusiness } from '@/lib/business-access'; +import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { leadStatusSchema } from '@/lib/validators'; function resolveSafeAppRedirect(value: FormDataEntryValue | null, fallback: string) { @@ -20,6 +21,10 @@ export async function updateLeadStatusAction(formData: FormData) { const redirectTo = resolveSafeAppRedirect(formData.get('redirectTo'), fallbackPath); const successRedirectTo = resolveSafeAppRedirect(formData.get('successRedirectTo'), redirectTo); + if (isPortfolioDemoMode()) { + redirect(`${redirectTo}${redirectTo.includes('?') ? '&' : '?'}error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`); + } + const business = await requireBusiness(); const parsed = leadStatusSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { diff --git a/app/app/settings/actions.ts b/app/app/settings/actions.ts index 0be9874..f901c7f 100644 --- a/app/app/settings/actions.ts +++ b/app/app/settings/actions.ts @@ -25,6 +25,7 @@ import { averageJobValueDollarsToCents } from '@/lib/business-settings'; import { db } from '@/lib/db'; import { formatPhoneDetail, maskSid, recordBusinessOperatorEvent } from '@/lib/operator-events'; import { maskPhoneForAudit, normalizePhoneNumber, normalizePhoneNumberToE164 } from '@/lib/phone'; +import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { getBusinessTwilioSaveErrorMessage, getMessagingComplianceSidValidationError, @@ -87,6 +88,10 @@ function redirectToSettingsError(message: string): never { } export async function saveBusinessSettingsAction(formData: FormData) { + if (isPortfolioDemoMode()) { + redirect(`/app/settings?error=${encodeURIComponent(PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE)}`); + } + const business = await getBusinessForOwner(); const parsed = businessSettingsSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { diff --git a/app/app/settings/page.tsx b/app/app/settings/page.tsx index 5e11494..17a78e1 100644 --- a/app/app/settings/page.tsx +++ b/app/app/settings/page.tsx @@ -11,7 +11,7 @@ import { getBusinessNotificationSettingsForBusiness } from '@/lib/business-acces import { getBusinessRoutingNumber, getPublicBusinessPhone } from '@/lib/business-phone-setup'; import { db } from '@/lib/db'; import { formatPhoneForDisplay } from '@/lib/phone'; -import { isPortfolioDemoMode } from '@/lib/portfolio-demo'; +import { isPortfolioDemoMode, PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { getCustomerSystemStatus } from '@/lib/system-status'; import { saveBusinessSettingsAction } from './actions'; @@ -133,6 +133,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re {error ?
{error}
: null} {saved ?
Business settings saved.
: null} + {demoMode ?
{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}
: null}
{statusCards.map((item) => ( @@ -157,6 +158,7 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re +
@@ -239,8 +241,11 @@ export default async function SettingsPage({ searchParams }: { searchParams?: Re
- +
+ diff --git a/components/home-dashboard.tsx b/components/home-dashboard.tsx index 238b00f..c7ec30b 100644 --- a/components/home-dashboard.tsx +++ b/components/home-dashboard.tsx @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { getLeadStatusBadgeVariant, leadStatusLabels } from '@/lib/lead-presenters'; +import { PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE } from '@/lib/portfolio-demo'; import { cn } from '@/lib/utils'; type DashboardFeedback = { @@ -48,13 +49,23 @@ function LeadStatusActionForm({ redirectTo, label, variant, + disabled = false, }: { leadId: string; status: 'CONTACTED' | 'BOOKED' | 'LOST'; redirectTo: string; label: string; variant?: 'default' | 'outline' | 'destructive'; + disabled?: boolean; }) { + if (disabled) { + return ( + + ); + } + return (
@@ -68,7 +79,7 @@ function LeadStatusActionForm({ ); } -function LeadAttentionCard({ lead }: { lead: DashboardLeadCard }) { +function LeadAttentionCard({ isDemoMode, lead }: { isDemoMode: boolean; lead: DashboardLeadCard }) { return ( @@ -100,9 +111,10 @@ function LeadAttentionCard({ lead }: { lead: DashboardLeadCard }) { Open lead - - + + + {isDemoMode ?

{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}

: null}
); @@ -149,6 +161,7 @@ export function HomeDashboard({
{feedback.error ?
{feedback.error}
: null} {feedback.saved ?
Lead updated.
: null} + {isDemoMode ?
{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE}
: null}
@@ -220,7 +233,7 @@ export function HomeDashboard({ ) : (
{attentionLeads.map((lead) => ( - + ))}
)} diff --git a/lib/portfolio-demo.ts b/lib/portfolio-demo.ts index 92107c9..86c1a0e 100644 --- a/lib/portfolio-demo.ts +++ b/lib/portfolio-demo.ts @@ -29,6 +29,8 @@ type LeadDetailRecord = Lead & { call: Call | null; messages: Message[]; ownerNo const DEMO_USER_ID = 'user_portfolio_demo'; const DEMO_BUSINESS_ID = 'biz_portfolio_demo'; +export const PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE = 'Demo mode only - actions are disabled.'; + export function isPortfolioDemoMode() { return process.env.PORTFOLIO_DEMO_MODE === '1'; } diff --git a/tests/portfolio-demo-actions.test.ts b/tests/portfolio-demo-actions.test.ts new file mode 100644 index 0000000..5472035 --- /dev/null +++ b/tests/portfolio-demo-actions.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +function read(relativePath: string) { + return readFileSync(path.join(process.cwd(), relativePath), 'utf8'); +} + +test('portfolio demo lead status actions are disabled in the UI and guarded on post', () => { + const portfolioDemo = read('lib/portfolio-demo.ts'); + const leadActions = read('app/app/leads/actions.ts'); + const homeDashboard = read('components/home-dashboard.tsx'); + const leadDetailPage = read('app/app/leads/[leadId]/page.tsx'); + + assert.match(portfolioDemo, /PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE = 'Demo mode only - actions are disabled\.'/); + assert.match(leadActions, /isPortfolioDemoMode\(\)/); + assert.match(leadActions, /PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE/); + assert.match(leadActions, /updateLeadStatusForBusiness/); + assert.match(leadActions, /if \(isPortfolioDemoMode\(\)\)[\s\S]*updateLeadStatusForBusiness/); + + assert.match(homeDashboard, /disabled=\{isDemoMode\} leadId=\{lead\.id\} label="Mark contacted"/); + assert.match(homeDashboard, /disabled=\{isDemoMode\} leadId=\{lead\.id\} label="Mark booked"/); + assert.match(homeDashboard, /title=\{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE\}/); + assert.match(homeDashboard, /isDemoMode \?
/); + + assert.match(leadDetailPage, /disabled=\{demoMode\}/); + assert.match(leadDetailPage, /label="Mark contacted"/); + assert.match(leadDetailPage, /label="Mark booked"/); + assert.match(leadDetailPage, /label="Mark lost"/); + assert.match(leadDetailPage, /title=\{PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE\}/); + assert.match(leadDetailPage, /demoMode \?
/); +}); + +test('portfolio demo settings and billing actions cannot mutate real services', () => { + const settingsPage = read('app/app/settings/page.tsx'); + const settingsActions = read('app/app/settings/actions.ts'); + const billingPage = read('app/app/billing/page.tsx'); + const stripeCheckoutRoute = read('app/api/stripe/checkout/route.ts'); + const stripePortalRoute = read('app/api/stripe/portal/route.ts'); + + assert.match(settingsPage, /
/); + assert.match(settingsPage, /disabled=\{demoMode\}/); + assert.match(settingsPage, /PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE/); + assert.match(settingsActions, /isPortfolioDemoMode\(\)/); + assert.match(settingsActions, /export async function saveBusinessSettingsAction[\s\S]*if \(isPortfolioDemoMode\(\)\)[\s\S]*averageJobValueCents/); + + assert.match(billingPage, /PORTFOLIO_DEMO_ACTIONS_DISABLED_MESSAGE/); + assert.match(billingPage, /disabled=\{demoMode \|\| !starterPriceId\}/); + assert.match(billingPage, /disabled=\{demoMode \|\| !growthPriceId\}/); + assert.match(billingPage, /disabled=\{demoMode\}/); + + assert.match(stripeCheckoutRoute, /isPortfolioDemoMode\(\)/); + assert.ok(stripeCheckoutRoute.indexOf('if (isPortfolioDemoMode())') < stripeCheckoutRoute.indexOf('stripe.checkout.sessions.create')); + assert.match(stripePortalRoute, /isPortfolioDemoMode\(\)/); + assert.ok(stripePortalRoute.indexOf('if (isPortfolioDemoMode())') < stripePortalRoute.indexOf('stripe.billingPortal.sessions.create')); +});