Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'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;

const timer = setInterval(() => {
elapsed += interval;
const t = elapsed / totalDuration;

if (t >= 1) {
setPhase('complete');
setProgress(100);
setVisibleFindings(findings.length);
setCurrentAgent(agents.length - 1);

// Reset after a pause
setTimeout(() => {
elapsed = 0;
setPhase('scanning');
setProgress(0);
setVisibleFindings(0);
setCurrentAgent(0);
}, 3000);
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

// 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 (
<div className="px-5 py-4">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
{isComplete ? (
<ShieldAlert className="h-4 w-4 text-orange-500" />
) : (
<Shield className="h-4 w-4 text-primary animate-pulse" />
)}
<span className="font-semibold text-sm">app.acme.com</span>
{isComplete ? (
<span className="rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2 py-0.5 text-[10px] font-medium">
Complete
</span>
) : (
<span className="rounded-full bg-primary/10 text-primary px-2 py-0.5 text-[10px] font-medium">
Running
</span>
)}
</div>
<span className="text-xs text-muted-foreground font-mono">{progress}%</span>
</div>

{/* Progress bar */}
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden mb-4">
<div
className="h-full rounded-full transition-all duration-100 ease-out"
style={{
width: `${progress}%`,
backgroundColor: isComplete ? 'var(--color-green-500, #22c55e)' : 'var(--color-primary)',
}}
/>
</div>

{/* Current agent */}
{!isComplete && (
<div className="flex items-center gap-2 mb-4 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin text-primary" />
<span>{agents[currentAgent]}…</span>
<span className="ml-auto">{currentAgent + 1}/{agents.length} agents</span>
</div>
)}

{isComplete && (
<div className="flex items-center gap-2 mb-4 text-xs text-muted-foreground">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span>Scan complete — {findings.length} findings</span>
<span className="ml-auto">5/5 agents</span>
</div>
)}

{/* Findings */}
<div className="space-y-0 divide-y">
{findings.slice(0, visibleFindings).map((finding, i) => (
<div
key={finding.title}
className="flex items-center justify-between py-2.5 animate-in fade-in slide-in-from-bottom-1 duration-300"
style={{ animationDelay: `${i * 50}ms` }}
>
<div className="flex items-center gap-2.5 min-w-0">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-xs font-medium truncate">{finding.title}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate">{finding.location}</p>
</div>
</div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${severityColors[finding.severity]}`}>
{finding.severity}
</span>
</div>
))}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ export default async function PenetrationTestsPage({
redirect('/');
}

return <PenetrationTestsPageClient orgId={orgId} />;
const subscription = await db.pentestSubscription.findUnique({
where: { organizationId: orgId },
});

const hasActiveSubscription = subscription?.status === 'active';

return <PenetrationTestsPageClient orgId={orgId} hasActiveSubscription={hasActiveSubscription} />;
}

export async function generateMetadata(): Promise<Metadata> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ vi.mock('@comp/ui/badge', () => ({
Badge: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));

vi.mock('@comp/ui/module-gate', () => ({
ModuleGate: ({ title, description, action, features }: { title: string; description?: string; action?: ReactNode; features?: string[] }) => (
<div data-testid="module-gate">
<h2>{title}</h2>
{description && <p>{description}</p>}
{features?.map((f: string) => <span key={f}>{f}</span>)}
{action}
</div>
),
}));

vi.mock('./components/pentest-preview-animation', () => ({
PentestPreviewAnimation: () => <div data-testid="pentest-preview" />,
}));

vi.mock('./actions/billing', () => ({
subscribeToPentestPlan: vi.fn(),
}));

vi.mock('@trycompai/design-system', () => ({
Button: ({ asChild, children, ...props }: ComponentProps<'button'> & { asChild?: boolean }) => {
if (asChild && isValidElement(children)) {
Expand Down Expand Up @@ -196,8 +215,16 @@ describe('PenetrationTestsPageClient', () => {
} as ReturnType<typeof pentestHooks.useGithubRepos>);
});

it('renders locked state when subscription is not active', () => {
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={false} />);

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(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getAllByText('No reports yet')).toHaveLength(2);
expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument();
Expand All @@ -211,7 +238,7 @@ describe('PenetrationTestsPageClient', () => {
resetError: vi.fn(),
});

const { getByText } = render(<PenetrationTestsPageClient orgId="org_123" />);
const { getByText } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

fireEvent.click(getByText('Create your first report'));

Expand All @@ -229,7 +256,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [reportRows[1]],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('1 completed report')).toBeInTheDocument();
});
Expand All @@ -244,7 +271,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('2 reports in progress')).toBeInTheDocument();
});
Expand All @@ -259,7 +286,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('2 completed reports')).toBeInTheDocument();
});
Expand Down Expand Up @@ -292,7 +319,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('In progress (0/2)')).toBeInTheDocument();
});
Expand All @@ -307,7 +334,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [reportRows[1]],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('https://running.example.com')).toBeInTheDocument();
expect(screen.getByText('https://completed.example.com')).toBeInTheDocument();
Expand Down Expand Up @@ -343,7 +370,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
Expand All @@ -359,7 +386,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [],
});

const { container } = render(<PenetrationTestsPageClient orgId="org_123" />);
const { container } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(container.querySelector('.animate-spin')).toBeTruthy();
});
Expand Down Expand Up @@ -392,7 +419,7 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

expect(screen.getByText('In progress (1/2)')).toBeInTheDocument();
});
Expand Down Expand Up @@ -425,14 +452,14 @@ describe('PenetrationTestsPageClient', () => {
completedReports: [],
});

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

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(<PenetrationTestsPageClient orgId="org_123" />);
const { getByText, getByLabelText } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(getByText('Create Report'));
Expand Down Expand Up @@ -463,7 +490,7 @@ describe('PenetrationTestsPageClient', () => {
});

it('requires target URL before submitting report request', async () => {
render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);
const submitForm = screen.getByText('Start penetration test').closest('form');

await act(async () => {
Expand All @@ -477,7 +504,7 @@ describe('PenetrationTestsPageClient', () => {
});

it('creates a report without repository URL when only target is provided', async () => {
const { getByText, getByLabelText } = render(<PenetrationTestsPageClient orgId="org_123" />);
const { getByText, getByLabelText } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(getByText('Create Report'));
Expand All @@ -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(<PenetrationTestsPageClient orgId="org_123" />);
const { getByText, getByLabelText } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(getByText('Create Report'));
Expand Down Expand Up @@ -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(<PenetrationTestsPageClient orgId="org_123" />);
const { getByText, getByLabelText } = render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(getByText('Create Report'));
Expand All @@ -557,7 +584,7 @@ describe('PenetrationTestsPageClient', () => {
});

it('shows a Connect GitHub button when GitHub is not connected', async () => {
render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(screen.getByText('Create Report'));
Expand All @@ -579,7 +606,7 @@ describe('PenetrationTestsPageClient', () => {
isLoading: false,
} as ReturnType<typeof pentestHooks.useGithubRepos>);

render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(screen.getByText('Create Report'));
Expand All @@ -590,7 +617,7 @@ describe('PenetrationTestsPageClient', () => {
});

it('starts GitHub OAuth when Connect GitHub button is clicked', async () => {
render(<PenetrationTestsPageClient orgId="org_123" />);
render(<PenetrationTestsPageClient orgId="org_123" hasActiveSubscription={true} />);

await act(async () => {
fireEvent.click(screen.getByText('Create Report'));
Expand Down
Loading