diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts index d415e677c..3ca39a0a8 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts @@ -6,7 +6,7 @@ import { stripe } from '@/lib/stripe'; import { db } from '@db'; import { headers } from 'next/headers'; -async function requireOrgMember(orgId: string): Promise { +async function requireOrgAdmin(orgId: string): Promise { const response = await auth.api.getSession({ headers: await headers() }); if (!response?.session) { throw new Error('Unauthorized'); @@ -14,6 +14,25 @@ async function requireOrgMember(orgId: string): Promise { if (response.session.activeOrganizationId !== orgId) { throw new Error('Unauthorized'); } + + const userId = (response as { user?: { id?: string } }).user?.id; + if (!userId) { + throw new Error('Unauthorized'); + } + + // Verify user is an admin or owner — billing actions should not be available to regular members + const member = await db.member.findFirst({ + where: { + userId, + organizationId: orgId, + deactivated: false, + role: { in: ['admin', 'owner'] }, + }, + }); + + if (!member) { + throw new Error('Billing actions require admin or owner role.'); + } } async function getOrgBillingUrl(orgId: string): Promise { @@ -27,13 +46,21 @@ async function getOrgBillingUrl(orgId: string): Promise { export async function subscribeToPentestPlan( orgId: string, ): Promise<{ url: string }> { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); const returnBaseUrl = await getOrgBillingUrl(orgId); if (!stripe) { throw new Error('Stripe is not configured.'); } + // Guard against creating duplicate subscriptions + const existingSub = await db.pentestSubscription.findUnique({ + where: { organizationId: orgId }, + }); + if (existingSub?.status === 'active') { + throw new Error('Organization already has an active pentest subscription.'); + } + const priceId = env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID; if (!priceId) { throw new Error('STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID is not configured.'); @@ -93,7 +120,7 @@ export async function handleSubscriptionSuccess( orgId: string, sessionId: string, ): Promise { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); if (!stripe) { throw new Error('Stripe is not configured.'); @@ -179,7 +206,7 @@ export async function handleSubscriptionSuccess( export async function createBillingPortalSession( orgId: string, ): Promise<{ url: string }> { - await requireOrgMember(orgId); + await requireOrgAdmin(orgId); const returnUrl = await getOrgBillingUrl(orgId); if (!stripe) { @@ -202,18 +229,22 @@ export async function createBillingPortalSession( return { url: portalSession.url }; } -export async function checkAndChargePentestBilling(orgId: string, runId: string): Promise { - await requireOrgMember(orgId); +export interface PreauthorizeResult { + authorized: boolean; + isOverage: boolean; + error?: string; +} - // Verify the run exists and belongs to this org to prevent arbitrary runId abuse. - // runId here is the provider run ID (stored in providerRunId), not the internal ptr... key. - const run = await db.securityPenetrationTestRun.findUnique({ - where: { providerRunId: runId }, - select: { organizationId: true }, - }); - if (!run || run.organizationId !== orgId) { - throw new Error('Run not found.'); - } +export interface PentestPricing { + subscriptionPrice: string; // e.g. "$99/mo" + overagePrice: string; // e.g. "$199" +} + +export async function preauthorizePentestRun( + orgId: string, + nonce: string, +): Promise { + await requireOrgAdmin(orgId); const subscription = await db.pentestSubscription.findUnique({ where: { organizationId: orgId }, @@ -221,13 +252,11 @@ export async function checkAndChargePentestBilling(orgId: string, runId: string) }); if (!subscription) { - throw new Error( - `No active pentest subscription. Subscribe at /settings/billing.`, - ); + return { authorized: false, isOverage: false, error: 'No active pentest subscription. Subscribe at /settings/billing.' }; } if (subscription.status !== 'active') { - throw new Error('Pentest subscription is not active.'); + return { authorized: false, isOverage: false, error: 'Pentest subscription is not active.' }; } const runsThisPeriod = await db.securityPenetrationTestRun.count({ @@ -235,70 +264,128 @@ export async function checkAndChargePentestBilling(orgId: string, runId: string) organizationId: orgId, createdAt: { gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, + lt: subscription.currentPeriodEnd, }, }, }); - if (runsThisPeriod <= subscription.includedRunsPerPeriod) { - return; + if (runsThisPeriod < subscription.includedRunsPerPeriod) { + return { authorized: true, isOverage: false }; } + // Over limit — charge overage if (!stripe) { - throw new Error('Stripe is not configured.'); + return { authorized: false, isOverage: true, error: 'Stripe is not configured.' }; } const overagePriceId = env.STRIPE_PENTEST_OVERAGE_PRICE_ID; if (!overagePriceId) { - throw new Error('STRIPE_PENTEST_OVERAGE_PRICE_ID is not configured.'); + return { authorized: false, isOverage: true, error: 'STRIPE_PENTEST_OVERAGE_PRICE_ID is not configured.' }; } const price = await stripe.prices.retrieve(overagePriceId); const amount = price.unit_amount; if (!amount) { - throw new Error('Overage price has no unit amount.'); + return { authorized: false, isOverage: true, error: 'Overage price has no unit amount.' }; } const stripeCustomerId = subscription.organizationBilling.stripeCustomerId; - const customer = await stripe.customers.retrieve(stripeCustomerId, { - expand: ['invoice_settings.default_payment_method'], + // Try the subscription's default payment method first (Checkout often sets it here), + // then fall back to the customer's invoice_settings.default_payment_method. + const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId, { + expand: ['default_payment_method'], }); - if (customer.deleted) { - throw new Error('Stripe customer not found.'); + let paymentMethodId: string | undefined; + + const subPm = stripeSub.default_payment_method; + if (subPm) { + paymentMethodId = typeof subPm === 'string' ? subPm : subPm.id; } - const defaultPaymentMethod = customer.invoice_settings?.default_payment_method; - if (!defaultPaymentMethod) { - throw new Error('No payment method on file. Update billing at /settings/billing.'); + if (!paymentMethodId) { + const customer = await stripe.customers.retrieve(stripeCustomerId, { + expand: ['invoice_settings.default_payment_method'], + }); + + if (customer.deleted) { + return { authorized: false, isOverage: true, error: 'Stripe customer not found.' }; + } + + const custPm = customer.invoice_settings?.default_payment_method; + if (custPm) { + paymentMethodId = typeof custPm === 'string' ? custPm : custPm.id; + } } - const paymentMethodId = - typeof defaultPaymentMethod === 'string' - ? defaultPaymentMethod - : defaultPaymentMethod.id; - - // Idempotency key scoped to the specific run ID so concurrent creates - // never share a key and each overage run is charged exactly once. - const idempotencyKey = `pentest-overage-${orgId}-${runId}`; - - const paymentIntent = await stripe.paymentIntents.create( - { - customer: stripeCustomerId, - amount, - currency: 'usd', - payment_method: paymentMethodId, - confirm: true, - automatic_payment_methods: { - enabled: true, - allow_redirects: 'never', + if (!paymentMethodId) { + return { authorized: false, isOverage: true, error: 'No payment method on file. Update billing at /settings/billing.' }; + } + + const idempotencyKey = `pentest-overage-${orgId}-${nonce}`; + + try { + const paymentIntent = await stripe.paymentIntents.create( + { + customer: stripeCustomerId, + amount, + currency: 'usd', + payment_method: paymentMethodId, + confirm: true, + off_session: true, }, - }, - { idempotencyKey }, - ); + { idempotencyKey }, + ); + + if (paymentIntent.status !== 'succeeded') { + return { authorized: false, isOverage: true, error: 'Overage payment failed. Check billing.' }; + } + } catch { + return { authorized: false, isOverage: true, error: 'Overage payment failed. Check billing.' }; + } + + return { authorized: true, isOverage: true }; +} + +function formatStripePrice(unitAmount: number | null, currency: string, interval?: string | null): string { + const amount = (unitAmount ?? 0) / 100; + const formatted = new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); + if (interval) { + const shortInterval = interval === 'month' ? 'mo' : interval === 'year' ? 'yr' : interval; + return `${formatted}/${shortInterval}`; + } + return formatted; +} + +export async function getPentestPricing(): Promise { + const fallback: PentestPricing = { subscriptionPrice: '$99/mo', overagePrice: '$199' }; + + if (!stripe) return fallback; + + const subPriceId = env.STRIPE_PENTEST_SUBSCRIPTION_PRICE_ID; + const overagePriceId = env.STRIPE_PENTEST_OVERAGE_PRICE_ID; - if (paymentIntent.status !== 'succeeded') { - throw new Error('Overage payment failed. Check billing.'); + try { + const [subPrice, overagePrice] = await Promise.all([ + subPriceId ? stripe.prices.retrieve(subPriceId) : null, + overagePriceId ? stripe.prices.retrieve(overagePriceId) : null, + ]); + + return { + subscriptionPrice: subPrice + ? formatStripePrice(subPrice.unit_amount, subPrice.currency, subPrice.recurring?.interval) + : fallback.subscriptionPrice, + overagePrice: overagePrice + ? formatStripePrice(overagePrice.unit_amount, overagePrice.currency) + : fallback.overagePrice, + }; + } catch { + return fallback; } } diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx new file mode 100644 index 000000000..893748b3c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/components/pentest-preview-animation.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Loader2, Shield, ShieldAlert } from 'lucide-react'; + +interface Finding { + severity: 'critical' | 'high' | 'medium' | 'low'; + title: string; + location: string; +} + +const findings: Finding[] = [ + { severity: 'critical', title: 'SQL Injection in /api/users', location: 'POST /api/users?search=' }, + { severity: 'high', title: 'Stored XSS in comments', location: 'POST /api/comments' }, + { severity: 'high', title: 'Broken access control', location: 'GET /api/admin/settings' }, + { severity: 'medium', title: 'Missing rate limiting', location: 'POST /api/auth/login' }, + { severity: 'medium', title: 'Insecure CORS policy', location: 'Origin: *' }, + { severity: 'low', title: 'Missing security headers', location: 'X-Frame-Options' }, +]; + +const agents = [ + 'Reconnaissance', + 'Authentication testing', + 'Injection testing', + 'Access control audit', + 'Configuration review', +]; + +const severityColors = { + critical: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400', + high: 'bg-orange-100 text-orange-700 dark:bg-orange-950/40 dark:text-orange-400', + medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400', + low: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400', +}; + +export function PentestPreviewAnimation() { + const [progress, setProgress] = useState(0); + const [currentAgent, setCurrentAgent] = useState(0); + const [visibleFindings, setVisibleFindings] = useState(0); + const [phase, setPhase] = useState<'scanning' | 'complete'>('scanning'); + + useEffect(() => { + const totalDuration = 8000; + const interval = 50; + let elapsed = 0; + + let pausing = false; + + const timer = setInterval(() => { + if (pausing) return; + + elapsed += interval; + const t = elapsed / totalDuration; + + if (t >= 1) { + setPhase('complete'); + setProgress(100); + setVisibleFindings(findings.length); + setCurrentAgent(agents.length - 1); + + // Pause, then reset once + pausing = true; + setTimeout(() => { + elapsed = 0; + pausing = false; + setPhase('scanning'); + setProgress(0); + setVisibleFindings(0); + setCurrentAgent(0); + }, 3000); + return; + } + + // Progress with easing + const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + setProgress(Math.round(eased * 100)); + + // Cycle through agents + setCurrentAgent(Math.min(Math.floor(t * agents.length), agents.length - 1)); + + // Reveal findings progressively + setVisibleFindings(Math.floor(t * (findings.length + 1))); + }, interval); + + return () => clearInterval(timer); + }, []); + + const isComplete = phase === 'complete'; + + return ( +
+ {/* Header */} +
+
+ {isComplete ? ( + + ) : ( + + )} + app.acme.com + {isComplete ? ( + + Complete + + ) : ( + + Running + + )} +
+ {progress}% +
+ + {/* Progress bar */} +
+
+
+ + {/* Current agent */} + {!isComplete && ( +
+ + {agents[currentAgent]}… + {currentAgent + 1}/{agents.length} agents +
+ )} + + {isComplete && ( +
+ + Scan complete — {findings.length} findings + 5/5 agents +
+ )} + + {/* Findings */} +
+ {findings.slice(0, visibleFindings).map((finding, i) => ( +
+
+ +
+

{finding.title}

+

{finding.location}

+
+
+ + {finding.severity} + +
+ ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx index d780148f2..c1cce972e 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx @@ -11,7 +11,7 @@ import { } from './use-penetration-tests'; vi.mock('../actions/billing', () => ({ - checkAndChargePentestBilling: vi.fn().mockResolvedValue(undefined), + preauthorizePentestRun: vi.fn().mockResolvedValue({ authorized: true, isOverage: false }), })); vi.mock('@/utils/jwt-manager', () => ({ @@ -239,15 +239,13 @@ describe('use-penetration-tests hooks', () => { expect(requestBody.repoUrl).toBeUndefined(); }); - it('billing action failure surfaces the error after run creation', async () => { - fetchMock.mockResolvedValueOnce( - createJsonResponse({ id: 'run_billed', status: 'provisioning' }), - ); - - const { checkAndChargePentestBilling } = await import('../actions/billing'); - vi.mocked(checkAndChargePentestBilling).mockRejectedValueOnce( - new Error('No active pentest subscription.'), - ); + it('preauthorization rejection prevents run creation', async () => { + const { preauthorizePentestRun } = await import('../actions/billing'); + vi.mocked(preauthorizePentestRun).mockResolvedValueOnce({ + authorized: false, + isOverage: false, + error: 'No active pentest subscription.', + }); const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); @@ -259,10 +257,31 @@ describe('use-penetration-tests hooks', () => { ).rejects.toThrow('No active pentest subscription.'); }); - expect(fetchMock).toHaveBeenCalledOnce(); + // The API should never be called when preauthorization fails + expect(fetchMock).not.toHaveBeenCalled(); expect(result.current.error).toBe('No active pentest subscription.'); }); + it('preauthorization failure from thrown error surfaces the error', async () => { + const { preauthorizePentestRun } = await import('../actions/billing'); + vi.mocked(preauthorizePentestRun).mockRejectedValueOnce( + new Error('Billing authorization failed.'), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + }), + ).rejects.toThrow('Billing authorization failed.'); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.error).toBe('Billing authorization failed.'); + }); + it('surfaces json provider error objects from create response', async () => { fetchMock.mockResolvedValueOnce( new Response( diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts index 2f727a4c5..79140cf34 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -8,11 +8,11 @@ import type { PentestReportStatus, PentestRun, } from '@/lib/security/penetration-tests-client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useSWRConfig } from 'swr'; import useSWR from 'swr'; import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; -import { checkAndChargePentestBilling } from '../actions/billing'; +import { preauthorizePentestRun } from '../actions/billing'; const reportListEndpoint = '/v1/security-penetration-tests'; const githubReposEndpoint = '/v1/security-penetration-tests/github/repos'; @@ -237,12 +237,21 @@ export function useCreatePenetrationTest( const { mutate } = useSWRConfig(); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); + // Stable nonce per target URL — prevents duplicate overage charges if the + // API call fails after billing succeeds and the user retries. + const nonceRef = useRef(crypto.randomUUID()); const createReport = useCallback( async (payload: CreatePayload): Promise => { setIsCreating(true); setError(null); try { + // Preauthorize billing before creating the run + const authResult = await preauthorizePentestRun(organizationId, nonceRef.current); + if (!authResult.authorized) { + throw new Error(authResult.error ?? 'Billing authorization failed.'); + } + const response = await api.post<{ id?: string; status?: PentestReportStatus; @@ -269,8 +278,6 @@ export function useCreatePenetrationTest( throw new Error('Could not resolve report ID from create response.'); } - await checkAndChargePentestBilling(organizationId, reportId); - const data: CreatePenetrationTestResponse = { id: reportId, status: response.data?.status, @@ -315,6 +322,8 @@ export function useCreatePenetrationTest( } void mutate(reportListKey(organizationId)); void mutate(reportKey(organizationId, reportId)); + // Rotate nonce so the next submission gets a fresh idempotency key + nonceRef.current = crypto.randomUUID(); return data; } catch (reportError) { const message = diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx index 97ca9aba3..05bcfc04f 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx @@ -7,6 +7,8 @@ import PenetrationTestsPage, { generateMetadata } from './page'; const authGetSessionMock = vi.fn(); const dbFindFirstMock = vi.fn(); +const dbPentestSubFindUniqueMock = vi.fn(); +const dbRunCountMock = vi.fn(); const headersMock = vi.fn(); const childMock = vi.fn(); @@ -23,6 +25,12 @@ vi.mock('@db', () => ({ member: { findFirst: (...args: unknown[]) => dbFindFirstMock(...args), }, + pentestSubscription: { + findUnique: (...args: unknown[]) => dbPentestSubFindUniqueMock(...args), + }, + securityPenetrationTestRun: { + count: (...args: unknown[]) => dbRunCountMock(...args), + }, }, })); @@ -34,6 +42,10 @@ vi.mock('next/navigation', () => ({ redirect: vi.fn(), })); +vi.mock('./actions/billing', () => ({ + getPentestPricing: vi.fn().mockResolvedValue({ subscriptionPrice: '$99/mo', overagePrice: '$49' }), +})); + vi.mock('./penetration-tests-page-client', () => ({ PenetrationTestsPageClient: ({ orgId }: { orgId: string }) => { childMock(orgId); @@ -47,6 +59,8 @@ describe('Penetration Tests page', () => { headersMock.mockReturnValue(new Headers()); authGetSessionMock.mockResolvedValue({ user: { id: 'user_1' } }); dbFindFirstMock.mockResolvedValue({ id: 'member_1' }); + dbPentestSubFindUniqueMock.mockResolvedValue(null); + dbRunCountMock.mockResolvedValue(0); vi.mocked(redirect).mockImplementation(() => { const error = new Error('NEXT_REDIRECT'); (error as Error & { digest: string }).digest = 'NEXT_REDIRECT'; diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx index c51f4ad16..1fccf5a6e 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx @@ -4,6 +4,7 @@ import { headers } from 'next/headers'; import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { getPentestPricing } from './actions/billing'; import { PenetrationTestsPageClient } from './penetration-tests-page-client'; export default async function PenetrationTestsPage({ @@ -32,7 +33,42 @@ export default async function PenetrationTestsPage({ redirect('/'); } - return ; + const subscription = await db.pentestSubscription.findUnique({ + where: { organizationId: orgId }, + }); + + const hasActiveSubscription = subscription?.status === 'active'; + + let usage: { + includedRuns: number; + usedRuns: number; + remainingRuns: number; + currentPeriodEnd: string; + } | null = null; + + if (hasActiveSubscription && subscription) { + const usedRuns = await db.securityPenetrationTestRun.count({ + where: { + organizationId: orgId, + createdAt: { + gte: subscription.currentPeriodStart, + lt: subscription.currentPeriodEnd, + }, + }, + }); + + const includedRuns = subscription.includedRunsPerPeriod; + usage = { + includedRuns, + usedRuns, + remainingRuns: Math.max(0, includedRuns - usedRuns), + currentPeriodEnd: subscription.currentPeriodEnd.toISOString(), + }; + } + + const pricing = await getPentestPricing(); + + return ; } export async function generateMetadata(): Promise { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx index 7206308c0..1f46fe7a4 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx @@ -107,6 +107,25 @@ vi.mock('@comp/ui/badge', () => ({ Badge: ({ children }: { children: ReactNode }) => {children}, })); +vi.mock('@comp/ui/module-gate', () => ({ + ModuleGate: ({ title, description, action, features }: { title: string; description?: string; action?: ReactNode; features?: string[] }) => ( +
+

{title}

+ {description &&

{description}

} + {features?.map((f: string) => {f})} + {action} +
+ ), +})); + +vi.mock('./components/pentest-preview-animation', () => ({ + PentestPreviewAnimation: () =>
, +})); + +vi.mock('./actions/billing', () => ({ + subscribeToPentestPlan: vi.fn(), +})); + vi.mock('@trycompai/design-system', () => ({ Button: ({ asChild, children, ...props }: ComponentProps<'button'> & { asChild?: boolean }) => { if (asChild && isValidElement(children)) { @@ -196,8 +215,16 @@ describe('PenetrationTestsPageClient', () => { } as ReturnType); }); + it('renders locked state when subscription is not active', () => { + render(); + + expect(screen.getByText('Find vulnerabilities before attackers do')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Get started/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Create Report' })).not.toBeInTheDocument(); + }); + it('renders an empty state and call-to-action when no reports exist', () => { - render(); + render(); expect(screen.getAllByText('No reports yet')).toHaveLength(2); expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument(); @@ -211,7 +238,7 @@ describe('PenetrationTestsPageClient', () => { resetError: vi.fn(), }); - const { getByText } = render(); + const { getByText } = render(); fireEvent.click(getByText('Create your first report')); @@ -229,7 +256,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('1 completed report')).toBeInTheDocument(); }); @@ -244,7 +271,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }], }); - render(); + render(); expect(screen.getByText('2 reports in progress')).toBeInTheDocument(); }); @@ -259,7 +286,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], }); - render(); + render(); expect(screen.getByText('2 completed reports')).toBeInTheDocument(); }); @@ -292,7 +319,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (0/2)')).toBeInTheDocument(); }); @@ -307,7 +334,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [reportRows[1]], }); - render(); + render(); expect(screen.getByText('https://running.example.com')).toBeInTheDocument(); expect(screen.getByText('https://completed.example.com')).toBeInTheDocument(); @@ -343,7 +370,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument(); @@ -359,7 +386,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - const { container } = render(); + const { container } = render(); expect(container.querySelector('.animate-spin')).toBeTruthy(); }); @@ -392,7 +419,7 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); }); @@ -425,14 +452,14 @@ describe('PenetrationTestsPageClient', () => { completedReports: [], }); - render(); + render(); expect(screen.getByText('In progress')).toBeInTheDocument(); expect(screen.queryByText('(n/a/n/a)')).toBeNull(); }); it('creates a report and navigates to the report detail page', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -463,7 +490,7 @@ describe('PenetrationTestsPageClient', () => { }); it('requires target URL before submitting report request', async () => { - render(); + render(); const submitForm = screen.getByText('Start penetration test').closest('form'); await act(async () => { @@ -477,7 +504,7 @@ describe('PenetrationTestsPageClient', () => { }); it('creates a report without repository URL when only target is provided', async () => { - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -503,7 +530,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces errors when run creation fails', async () => { createReportMock.mockRejectedValue(new Error('No active pentest subscription.')); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -531,7 +558,7 @@ describe('PenetrationTestsPageClient', () => { it('surfaces a generic error message when run creation fails with non-error value', async () => { createReportMock.mockRejectedValue('service-down'); - const { getByText, getByLabelText } = render(); + const { getByText, getByLabelText } = render(); await act(async () => { fireEvent.click(getByText('Create Report')); @@ -557,7 +584,7 @@ describe('PenetrationTestsPageClient', () => { }); it('shows a Connect GitHub button when GitHub is not connected', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -579,7 +606,7 @@ describe('PenetrationTestsPageClient', () => { isLoading: false, } as ReturnType); - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -590,7 +617,7 @@ describe('PenetrationTestsPageClient', () => { }); it('starts GitHub OAuth when Connect GitHub button is clicked', async () => { - render(); + render(); await act(async () => { fireEvent.click(screen.getByText('Create Report')); @@ -602,4 +629,134 @@ describe('PenetrationTestsPageClient', () => { expect(startOAuthMock).toHaveBeenCalledWith('github', expect.any(String)); }); + + it('displays usage indicator when usage data is provided', () => { + render( + , + ); + + expect(screen.getByText('2/3 runs used this period')).toBeInTheDocument(); + }); + + it('shows dynamic dialog description based on remaining runs', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + expect(screen.getByText('You have 2 of 3 included runs remaining this period.')).toBeInTheDocument(); + }); + + it('shows overage warning in dialog when no remaining runs', async () => { + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + expect(screen.getByText('You have used all included runs. This run will be charged at $49.')).toBeInTheDocument(); + }); + + it('shows overage confirmation dialog when submitting with zero remaining runs', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + expect(screen.getByText('Overage charge')).toBeInTheDocument(); + expect(screen.getByText(/This run will be charged at \$49\. Continue\?/)).toBeInTheDocument(); + expect(createReportMock).not.toHaveBeenCalled(); + }); + + it('creates report after confirming overage dialog', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Confirm & start')); + }); + + await waitFor(() => { + expect(createReportMock).toHaveBeenCalledWith({ + targetUrl: 'https://example.com', + repoUrl: undefined, + }); + }); + }); + + it('does not create report when overage dialog is cancelled', async () => { + const { getByLabelText } = render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { target: { value: 'https://example.com' } }); + fireEvent.click(screen.getByText('Start penetration test')); + }); + + // Find the Cancel button in the overage dialog (there are multiple Cancel buttons) + const cancelButtons = screen.getAllByText('Cancel'); + await act(async () => { + fireEvent.click(cancelButtons[cancelButtons.length - 1]); + }); + + expect(createReportMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx index 87420edd0..e4d2850a3 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -27,7 +27,9 @@ import { TableHeader, TableRow, } from '@comp/ui/table'; +import { ModuleGate } from '@comp/ui/module-gate'; import { AlertCircle, Loader2 } from 'lucide-react'; +import { PentestPreviewAnimation } from './components/pentest-preview-animation'; import { useRouter } from 'next/navigation'; import { FormEvent, useState } from 'react'; import { toast } from 'sonner'; @@ -42,9 +44,21 @@ import { useIntegrationMutations, } from '@/hooks/use-integration-platform'; import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; +import { subscribeToPentestPlan } from './actions/billing'; +import type { PentestPricing } from './actions/billing'; + +interface PentestUsage { + includedRuns: number; + usedRuns: number; + remainingRuns: number; + currentPeriodEnd: string; +} interface PenetrationTestsPageClientProps { orgId: string; + hasActiveSubscription: boolean; + usage: PentestUsage | null; + pricing: PentestPricing; } const hasProtocol = (value: string): boolean => /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value); @@ -65,10 +79,13 @@ const normalizeTargetUrl = (value: string): string | null => { } }; -export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClientProps) { +export function PenetrationTestsPageClient({ orgId, hasActiveSubscription, usage, pricing }: PenetrationTestsPageClientProps) { const router = useRouter(); + const [isSubscribing, setIsSubscribing] = useState(false); const [showNewRunDialog, setShowNewRunDialog] = useState(false); + const [showOverageConfirm, setShowOverageConfirm] = useState(false); + const [pendingPayload, setPendingPayload] = useState<{ targetUrl: string; repoUrl?: string } | null>(null); const [targetUrl, setTargetUrl] = useState(''); const [repoUrl, setRepoUrl] = useState(''); const [isConnectingGithub, setIsConnectingGithub] = useState(false); @@ -100,6 +117,22 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient } }; + const executeCreateReport = async (payload: { targetUrl: string; repoUrl?: string }) => { + try { + const response = await createReport(payload); + + setTargetUrl(''); + setRepoUrl(''); + setShowNewRunDialog(false); + setShowOverageConfirm(false); + setPendingPayload(null); + toast.success('Penetration test queued successfully.'); + router.push(`/${orgId}/security/penetration-tests/${response.id}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); + } + }; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); const trimmedTargetUrl = targetUrl.trim(); @@ -113,37 +146,108 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient return; } - try { - const response = await createReport({ - targetUrl: normalizedTargetUrl, - repoUrl: repoUrl.trim() || undefined, - }); + const payload = { + targetUrl: normalizedTargetUrl, + repoUrl: repoUrl.trim() || undefined, + }; - setTargetUrl(''); - setRepoUrl(''); + // If no remaining runs, show overage confirmation first + if (usage && usage.remainingRuns === 0) { + setPendingPayload(payload); setShowNewRunDialog(false); - toast.success('Penetration test queued successfully.'); - router.push(`/${orgId}/security/penetration-tests/${response.id}`); + setShowOverageConfirm(true); + return; + } + + await executeCreateReport(payload); + }; + + const handleConfirmOverage = async () => { + if (!pendingPayload) return; + await executeCreateReport(pendingPayload); + }; + + const handleCancelOverage = () => { + setShowOverageConfirm(false); + setPendingPayload(null); + }; + + const handleSubscribe = async () => { + setIsSubscribing(true); + try { + const result = await subscribeToPentestPlan(orgId); + if (result.url) { + window.location.href = result.url; + } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); + toast.error(error instanceof Error ? error.message : 'Failed to start checkout'); + setIsSubscribing(false); } }; + if (!hasActiveSubscription) { + return ( + + + Run penetration tests with Maced and review generated reports. + + + + {isSubscribing ? ( + <> + + Redirecting… + + ) : ( + `Get started — ${pricing.subscriptionPrice}` + )} + + } + secondaryAction={ + + } + preview={} + /> + + ); + } + return ( setShowNewRunDialog(true)}>Create Report +
+ {usage && ( + + {usage.usedRuns}/{usage.includedRuns} runs used this period + + )} + +
} > - Run penetration tests with Maced and review generated reports.{' '} - - Manage subscription - + Run penetration tests with Maced and review generated reports.
@@ -151,8 +255,11 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient Queue a penetration test - Your subscription includes 3 penetration test runs per month. Additional runs are - charged as overage immediately. + {usage && usage.remainingRuns > 0 + ? `You have ${usage.remainingRuns} of ${usage.includedRuns} included runs remaining this period.` + : usage && usage.remainingRuns === 0 + ? `You have used all included runs. This run will be charged at ${pricing.overagePrice}.` + : 'Your subscription includes 1 penetration test run per month. Additional runs are charged as overage.'}
@@ -245,6 +352,32 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient
+ { if (!open) handleCancelOverage(); }}> + + + Overage charge + + You have used all {usage?.includedRuns ?? 1} included runs this period. This run will be charged at {pricing.overagePrice}. Continue? + + + + + + + + + Your reports ({reports.length}) diff --git a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx index ef9aa71ca..dc64e7799 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx @@ -1,7 +1,19 @@ import { auth } from '@/utils/auth'; import { db } from '@db'; +import { Alert, AlertDescription } from '@comp/ui/alert'; +import { Badge } from '@comp/ui/badge'; import { Button } from '@trycompai/design-system'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { Separator } from '@comp/ui/separator'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@comp/ui/table'; +import { InfoIcon } from 'lucide-react'; import { headers } from 'next/headers'; import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; @@ -44,9 +56,6 @@ export default async function BillingPage({ params, searchParams }: BillingPageP let errorMessage: string | null = null; if (success === 'true' && session_id) { - // The webhook (checkout.session.completed) is the primary activation path. - // handleSubscriptionSuccess is a same-request fallback for the case where - // the webhook hasn't fired yet when the user lands back on this page. try { await handleSubscriptionSuccess(orgId, session_id); successMessage = 'Subscription activated! You can now create penetration test runs.'; @@ -70,39 +79,240 @@ export default async function BillingPage({ params, searchParams }: BillingPageP organizationId: orgId, createdAt: { gte: subscription.currentPeriodStart, - lte: subscription.currentPeriodEnd, + lt: subscription.currentPeriodEnd, }, }, }) : null; + const formatDate = (date: Date) => + date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + + const pentestIsActive = subscription?.status === 'active'; + const pentestIsCancelled = subscription?.status === 'cancelled'; + const pentestIsPastDue = subscription?.status === 'past_due'; + return ( -
+
{successMessage && ( -
- {successMessage} -
+ + {successMessage} + )} {errorMessage && ( -
- {errorMessage} -
+ + {errorMessage} + )} + {/* Subscriptions */} - Payment & Billing + Subscriptions - Manage your payment method for all app subscriptions. + Manage your active products and add-ons. - - {billing ? ( -
-

- Stripe customer connected. -

+ + + + + Product + Status + Usage + Renewal + + + + + {/* Compliance */} + + +
+ Compliance + Managed by account team +
+
+ + Active + + Custom + Custom + +
+ + {/* Security — Penetration Testing */} + + +
+ Penetration Testing + Security add-on +
+
+ + {pentestIsActive ? ( + Active + ) : pentestIsCancelled ? ( + Cancelled + ) : pentestIsPastDue ? ( + Past due + ) : ( + Not subscribed + )} + + + {pentestIsActive && runsThisPeriod !== null ? ( + + {runsThisPeriod} + /{subscription.includedRunsPerPeriod} runs + + ) : ( + + )} + + + {pentestIsActive ? ( + formatDate(subscription.currentPeriodEnd) + ) : pentestIsCancelled && subscription ? ( + + Ends {formatDate(subscription.currentPeriodEnd)} + + ) : ( + + )} + + + {pentestIsActive && billing ? ( + { + 'use server'; + const { url } = await createBillingPortalSession(orgId); + redirect(url); + }} + > + + + ) : pentestIsPastDue && billing ? ( +
{ + 'use server'; + const { url } = await createBillingPortalSession(orgId); + redirect(url); + }} + > + + + ) : ( +
{ + 'use server'; + const { url } = await subscribeToPentestPlan(orgId); + redirect(url); + }} + > + + + )} +
+
+
+
+
+ + + {/* Penetration Testing — expanded details (only when active) */} + {pentestIsActive && subscription && ( + + +
+
+ Penetration Testing + + Current billing period: {formatDate(subscription.currentPeriodStart)} — {formatDate(subscription.currentPeriodEnd)} + +
+ Active +
+
+ +
+
+

Included runs

+

{subscription.includedRunsPerPeriod}

+

per billing period

+
+
+

Used this period

+

{runsThisPeriod ?? 0}

+

+ {runsThisPeriod !== null && runsThisPeriod > subscription.includedRunsPerPeriod + ? `${runsThisPeriod - subscription.includedRunsPerPeriod} overage` + : `${Math.max(0, subscription.includedRunsPerPeriod - (runsThisPeriod ?? 0))} remaining`} +

+
+
+

Member since

+

+ {subscription.createdAt.toLocaleDateString(undefined, { month: 'short', year: 'numeric' })} +

+

+ {formatDate(subscription.createdAt)} +

+
+
+ + {runsThisPeriod !== null && ( + <> + +
+
+

Period usage

+ + {runsThisPeriod} of {subscription.includedRunsPerPeriod} runs + +
+
+
subscription.includedRunsPerPeriod + ? 'bg-destructive' + : runsThisPeriod === subscription.includedRunsPerPeriod + ? 'bg-orange-500' + : 'bg-primary' + }`} + style={{ width: `${Math.min(100, (runsThisPeriod / subscription.includedRunsPerPeriod) * 100)}%` }} + /> +
+ {runsThisPeriod > subscription.includedRunsPerPeriod && ( +

+ {runsThisPeriod - subscription.includedRunsPerPeriod} overage run{runsThisPeriod - subscription.includedRunsPerPeriod !== 1 ? 's' : ''} billed this period +

+ )} +
+ + )} + + + )} + + {/* Payment method — only show when there's an active self-serve subscription */} + {billing && pentestIsActive && ( + + +
+
+ Payment method + + Used for self-serve subscriptions like Penetration Testing. + +
{ 'use server'; @@ -110,76 +320,23 @@ export default async function BillingPage({ params, searchParams }: BillingPageP redirect(url); }} > -
- ) : ( -

- Payment method will be set up when you subscribe to an app below. -

- )} - -
+ + + )} - - - Penetration Testing - - $99/month — Includes 3 penetration test runs per period. Additional runs charged as - overage at $49/run. - - - - {subscription && subscription.status === 'active' ? ( -
-
- Status - {subscription.status} -
-
- Included runs / period - {subscription.includedRunsPerPeriod} -
- {runsThisPeriod !== null && ( -
- Runs used this period - - {runsThisPeriod} / {subscription.includedRunsPerPeriod} - -
- )} -
- Period ends - - {subscription.currentPeriodEnd.toLocaleDateString()} - -
-
- ) : subscription && subscription.status === 'cancelled' ? ( -

- Your subscription has been cancelled. Subscribe below to resume. -

- ) : ( -

- No active subscription. Subscribe to start running penetration tests. -

- )} - - {(!subscription || subscription.status === 'cancelled') && ( -
{ - 'use server'; - const { url } = await subscribeToPentestPlan(orgId); - redirect(url); - }} - > - -
- )} -
-
+ {/* Account-managed billing note */} +
+ +

+ Compliance billing is managed by your account team. For invoices, payment changes, or + billing questions related to Compliance, contact your account manager. +

+
); } diff --git a/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts new file mode 100644 index 000000000..b9b4baab4 --- /dev/null +++ b/apps/app/src/app/api/webhooks/stripe-pentest/route.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './route'; +import type Stripe from 'stripe'; + +// ─── Mocks ──────────────────────────────────────────────── + +const constructEventMock = vi.fn(); +const subscriptionsRetrieveMock = vi.fn(); + +vi.mock('@/lib/stripe', () => ({ + stripe: { + webhooks: { constructEvent: (...args: unknown[]) => constructEventMock(...args) }, + subscriptions: { retrieve: (...args: unknown[]) => subscriptionsRetrieveMock(...args) }, + }, +})); + +vi.mock('@/env.mjs', () => ({ + env: { STRIPE_PENTEST_WEBHOOK_SECRET: 'whsec_test_secret' }, +})); + +const dbBillingFindFirstMock = vi.fn(); +const dbPentestSubUpsertMock = vi.fn(); +const dbPentestSubUpdateManyMock = vi.fn(); + +vi.mock('@db', () => ({ + db: { + organizationBilling: { + findFirst: (...args: unknown[]) => dbBillingFindFirstMock(...args), + }, + pentestSubscription: { + upsert: (...args: unknown[]) => dbPentestSubUpsertMock(...args), + updateMany: (...args: unknown[]) => dbPentestSubUpdateManyMock(...args), + }, + }, +})); + +const headersMock = vi.fn(); +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +// ─── Helpers ────────────────────────────────────────────── + +function makeRequest(body: string): Request { + return new Request('http://localhost/api/webhooks/stripe-pentest', { + method: 'POST', + body, + }); +} + +function makeStripeEvent(type: string, data: unknown): Stripe.Event { + return { + id: 'evt_test', + type, + data: { object: data }, + object: 'event', + api_version: '2026-01-28.clover', + created: Date.now() / 1000, + livemode: false, + pending_webhooks: 0, + request: null, + } as unknown as Stripe.Event; +} + +const PERIOD_START = 1709568000; // Mar 4 2024 +const PERIOD_END = 1712246400; // Apr 4 2024 + +function makeSubscriptionItem(priceId = 'price_test') { + return { + price: { id: priceId }, + current_period_start: PERIOD_START, + current_period_end: PERIOD_END, + }; +} + +// ─── Tests ──────────────────────────────────────────────── + +describe('Stripe Pentest Webhook', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers({ 'stripe-signature': 'sig_test' })); + }); + + it('returns 400 when stripe-signature header is missing', async () => { + headersMock.mockReturnValue(new Headers()); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Missing stripe-signature header'); + }); + + it('returns 400 when signature verification fails', async () => { + constructEventMock.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + const res = await POST(makeRequest('{}')); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Invalid signature'); + }); + + // ── checkout.session.completed ────────────────────────── + + describe('checkout.session.completed', () => { + it('creates pentest subscription on successful checkout', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_test', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue({ + id: 'billing_1', + organizationId: 'org_1', + }); + subscriptionsRetrieveMock.mockResolvedValue({ + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + dbPentestSubUpsertMock.mockResolvedValue({}); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbBillingFindFirstMock).toHaveBeenCalledWith({ + where: { stripeCustomerId: 'cus_test' }, + }); + expect(subscriptionsRetrieveMock).toHaveBeenCalledWith('sub_test'); + expect(dbPentestSubUpsertMock).toHaveBeenCalledWith( + expect.objectContaining({ + where: { organizationId: 'org_1' }, + create: expect.objectContaining({ + organizationId: 'org_1', + organizationBillingId: 'billing_1', + stripeSubscriptionId: 'sub_test', + stripePriceId: 'price_test', + status: 'active', + }), + }), + ); + }); + + it('ignores non-subscription checkout sessions', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'payment', + customer: 'cus_test', + }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbBillingFindFirstMock).not.toHaveBeenCalled(); + }); + + it('ignores checkout for unknown customer', async () => { + const event = makeStripeEvent('checkout.session.completed', { + mode: 'subscription', + customer: 'cus_unknown', + subscription: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbBillingFindFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + }); + }); + + // ── customer.subscription.updated ─────────────────────── + + describe('customer.subscription.updated', () => { + it('updates subscription status and period dates', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'active', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { + status: 'active', + currentPeriodStart: new Date(PERIOD_START * 1000), + currentPeriodEnd: new Date(PERIOD_END * 1000), + }, + }); + }); + + it('stores past_due status from Stripe', async () => { + const event = makeStripeEvent('customer.subscription.updated', { + id: 'sub_test', + status: 'past_due', + items: { data: [makeSubscriptionItem()] }, + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'past_due' }), + }), + ); + }); + }); + + // ── customer.subscription.deleted ─────────────────────── + + describe('customer.subscription.deleted', () => { + it('sets status to cancelled', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockResolvedValue({ count: 1 }); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + + expect(dbPentestSubUpdateManyMock).toHaveBeenCalledWith({ + where: { stripeSubscriptionId: 'sub_test' }, + data: { status: 'cancelled' }, + }); + }); + }); + + // ── unknown events ────────────────────────────────────── + + it('ignores unknown event types', async () => { + const event = makeStripeEvent('invoice.paid', { id: 'inv_test' }); + constructEventMock.mockReturnValue(event); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(200); + expect(dbPentestSubUpsertMock).not.toHaveBeenCalled(); + expect(dbPentestSubUpdateManyMock).not.toHaveBeenCalled(); + }); + + // ── error handling ────────────────────────────────────── + + it('returns 500 when handler throws', async () => { + const event = makeStripeEvent('customer.subscription.deleted', { + id: 'sub_test', + }); + constructEventMock.mockReturnValue(event); + dbPentestSubUpdateManyMock.mockRejectedValue(new Error('DB error')); + + const res = await POST(makeRequest('body')); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe('Webhook handler failed'); + }); +}); diff --git a/packages/db/prisma/schema/pentest-subscription.prisma b/packages/db/prisma/schema/pentest-subscription.prisma index 791632998..44d44bfd0 100644 --- a/packages/db/prisma/schema/pentest-subscription.prisma +++ b/packages/db/prisma/schema/pentest-subscription.prisma @@ -6,7 +6,7 @@ model PentestSubscription { stripePriceId String @map("stripe_price_id") stripeOveragePriceId String? @map("stripe_overage_price_id") status String @default("active") // active | cancelled | past_due - includedRunsPerPeriod Int @default(3) @map("included_runs_per_period") + includedRunsPerPeriod Int @default(1) @map("included_runs_per_period") currentPeriodStart DateTime @map("current_period_start") currentPeriodEnd DateTime @map("current_period_end") createdAt DateTime @default(now()) @map("created_at") diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index ba1cb02c5..52240bee5 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -8288,6 +8288,44 @@ ] } }, + "/v1/internal/tasks/notify-bulk-automation-failures": { + "post": { + "operationId": "InternalTaskNotificationController_notifyBulkAutomationFailures_v1", + "parameters": [ + { + "name": "X-Internal-Token", + "in": "header", + "description": "Internal service token (required in production)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotifyBulkAutomationFailuresDto" + } + } + } + }, + "responses": { + "200": { + "description": "Notifications sent" + }, + "500": { + "description": "Notification delivery failed" + } + }, + "summary": "Send consolidated automation failure digest (email + in-app) for an org (internal)", + "tags": [ + "Internal - Tasks" + ] + } + }, "/v1/tasks/{taskId}/automations": { "get": { "description": "Retrieve all automations for a specific task", @@ -16227,6 +16265,51 @@ "tags": [ "Evidence Forms" ] + }, + "delete": { + "description": "Remove an evidence form submission for the active organization. Requires owner, admin, or auditor role.", + "operationId": "EvidenceFormsController_deleteSubmission_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "formType", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "submissionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Delete a submission", + "tags": [ + "Evidence Forms" + ] } }, "/v1/evidence-forms/{formType}/submissions": { @@ -16497,6 +16580,37 @@ ] } }, + "/v1/security-penetration-tests/github/repos": { + "get": { + "description": "Returns GitHub repositories accessible with the connected GitHub integration.", + "operationId": "SecurityPenetrationTestsController_listGithubRepos_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Repository list returned" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "List accessible GitHub repositories", + "tags": [ + "Security Penetration Tests" + ] + } + }, "/v1/security-penetration-tests/{id}": { "get": { "description": "Returns a penetration test run with progress metadata.", @@ -16658,7 +16772,7 @@ }, "/v1/security-penetration-tests/webhook": { "post": { - "description": "Receives callback payloads from the penetration test provider when a report is updated. Per-run webhook token validation is enforced when handshake state exists.", + "description": "Receives callback payloads from the penetration test provider when a run is updated. Per-run webhook token validation is enforced when handshake state exists.", "operationId": "SecurityPenetrationTestsController_handleWebhook_v1", "parameters": [ { @@ -16677,13 +16791,6 @@ "description": "Per-job webhook token used for handshake validation when callbacks are sent to Comp.", "schema": {} }, - { - "name": "orgId", - "required": false, - "in": "query", - "description": "Organization context for webhook processing when X-Organization-Id is not provided.", - "schema": {} - }, { "name": "X-Webhook-Token", "in": "header", @@ -19059,6 +19166,53 @@ "taskStatusChanged" ] }, + "FailedTaskDto": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "description": "Task ID" + }, + "taskTitle": { + "type": "string", + "description": "Task title" + }, + "failedCount": { + "type": "number", + "description": "Number of failed automations for this task" + }, + "totalCount": { + "type": "number", + "description": "Total number of automations for this task" + } + }, + "required": [ + "taskId", + "taskTitle", + "failedCount", + "totalCount" + ] + }, + "NotifyBulkAutomationFailuresDto": { + "type": "object", + "properties": { + "organizationId": { + "type": "string", + "description": "Organization ID" + }, + "tasks": { + "description": "Array of failed tasks with counts", + "type": "array", + "items": { + "$ref": "#/components/schemas/FailedTaskDto" + } + } + }, + "required": [ + "organizationId", + "tasks" + ] + }, "UpdateAutomationDto": { "type": "object", "properties": { @@ -20691,11 +20845,6 @@ "type": "string", "description": "Workspace identifier used by the pentest engine" }, - "mockCheckout": { - "type": "boolean", - "description": "Set false to reject non-mocked checkout flows for strict behavior", - "default": true - }, "webhookUrl": { "type": "string", "description": "Optional webhook URL to notify when report generation completes" diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 6cefbbfcd..46d173cec 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -25,6 +25,7 @@ export * from './dialog'; export * from './drawer'; export * from './dropdown-menu'; export * from './empty-card'; +export * from './module-gate'; export * from './form'; export * from './hover-card'; export * from './icons'; diff --git a/packages/ui/src/components/module-gate.tsx b/packages/ui/src/components/module-gate.tsx new file mode 100644 index 000000000..488611631 --- /dev/null +++ b/packages/ui/src/components/module-gate.tsx @@ -0,0 +1,78 @@ +import { Check } from 'lucide-react'; +import type React from 'react'; +import { cn } from '../utils/cn'; + +interface ModuleGateProps { + /** Small uppercase label above the headline (e.g. "Penetration Testing") */ + label: string; + /** Bold headline — should sell the outcome, not describe the feature */ + title: string; + /** One-sentence value prop */ + description: string; + /** Checklist of what's included */ + features?: string[]; + /** Primary CTA */ + action: React.ReactNode; + /** Optional secondary CTA rendered next to the primary */ + secondaryAction?: React.ReactNode; + /** Optional product preview shown below the CTA — rendered inside a dark app-chrome frame */ + preview?: React.ReactNode; + className?: string; +} + +export function ModuleGate({ + label, + title, + description, + features, + action, + secondaryAction, + preview, + className, +}: ModuleGateProps) { + return ( +
+
+

+ {label} +

+

{title}

+

{description}

+
+ + {features && features.length > 0 && ( +
    + {features.map((item) => ( +
  • + + {item} +
  • + ))} +
+ )} + +
+ {action} + {secondaryAction} +
+ + {preview && ( +
+ {/* Dark chrome bar */} +
+ + + +
+ {/* Content area */} +
+ {preview} +
+
+ )} +
+ ); +}