diff --git a/apps/api/README.md b/apps/api/README.md index 08c43e7e9a..f7946b877b 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -25,6 +25,11 @@ [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +## Feature docs + +- Security penetration tests integration: + - `src/security-penetration-tests/README.md` + ## Project setup ```bash diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 012a3c5505..131df119c6 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -36,6 +36,7 @@ import { AssistantChatModule } from './assistant-chat/assistant-chat.module'; import { OrgChartModule } from './org-chart/org-chart.module'; import { TrainingModule } from './training/training.module'; import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module'; +import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; @Module({ imports: [ @@ -84,6 +85,7 @@ import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module'; TrainingModule, OrgChartModule, EvidenceFormsModule, + SecurityPenetrationTestsModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/security-penetration-tests/README.md b/apps/api/src/security-penetration-tests/README.md new file mode 100644 index 0000000000..234c01d2c3 --- /dev/null +++ b/apps/api/src/security-penetration-tests/README.md @@ -0,0 +1,40 @@ +# Security Penetration Tests (Maced Integration) + +This module exposes Comp API endpoints under `/v1/security-penetration-tests` and orchestrates report generation with Maced (`/v1/pentests`). + +## Endpoints + +- `GET /v1/security-penetration-tests` +- `POST /v1/security-penetration-tests` +- `GET /v1/security-penetration-tests/:id` +- `GET /v1/security-penetration-tests/:id/progress` +- `GET /v1/security-penetration-tests/:id/report` +- `GET /v1/security-penetration-tests/:id/pdf` +- `POST /v1/security-penetration-tests/webhook` + +## Required environment variables + +- `MACED_API_KEY`: Maced API key used by Nest API when calling provider endpoints. + +## Optional environment variables + +- `MACED_API_BASE_URL`: Defaults to `https://api.maced.ai`. +- `SECURITY_PENETRATION_TESTS_WEBHOOK_URL`: Base callback URL for Comp webhook endpoint. + +## Webhook handshake model + +1. On create (`POST /v1/security-penetration-tests`), Maced issues a per-job `webhookToken` and returns it in the create response. +2. Comp does not send a user-provided `webhookToken` upstream; the value is reserved for provider issuance. +3. If callback target resolves to Comp webhook route and Maced returns `webhookToken`, Comp persists a handshake record in `secrets` using name: + - `security_penetration_test_webhook_` +4. On webhook receive, Comp: + - resolves org context (`X-Organization-Id` or `orgId`/`organizationId` query), + - resolves token (`webhookToken` query or `X-Webhook-Token` header), + - requires a persisted per-job handshake and verifies token hash match, + - tracks idempotency (`X-Webhook-Id`/`X-Request-Id`, plus payload hash fallback), + - returns `duplicate: true` for replayed webhook events. + +## Notes + +- Frontend should call Nest API only (no Next.js proxy routes for this feature). +- Provider callbacks to non-Comp webhook URLs are passed through and are not forced to include Comp-specific webhook tokens. diff --git a/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts b/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts new file mode 100644 index 0000000000..90494bd83c --- /dev/null +++ b/apps/api/src/security-penetration-tests/dto/create-penetration-test.dto.ts @@ -0,0 +1,80 @@ +import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePenetrationTestDto { + @ApiProperty({ + description: 'Target URL for the penetration test scan', + example: 'https://app.example.com', + }) + @IsUrl() + targetUrl!: string; + + @ApiProperty({ + description: 'Repository URL containing the target application code', + example: 'https://github.com/org/repo', + required: false, + }) + @IsOptional() + @IsUrl() + repoUrl?: string; + + @ApiPropertyOptional({ + description: 'GitHub token used for cloning private repositories', + required: false, + }) + @IsOptional() + @IsString() + githubToken?: string; + + @ApiPropertyOptional({ + description: 'Optional YAML configuration for the pentest run', + required: false, + }) + @IsOptional() + @IsString() + configYaml?: string; + + @ApiPropertyOptional({ + description: 'Whether to enable pipeline testing mode', + required: false, + default: false, + }) + @IsOptional() + @IsBoolean() + pipelineTesting?: boolean; + + @ApiPropertyOptional({ + description: 'Workspace identifier used by the pentest engine', + required: false, + }) + @IsOptional() + @IsString() + workspace?: string; + + @ApiPropertyOptional({ + description: + 'Set false to reject non-mocked checkout flows for strict behavior', + required: false, + default: true, + }) + @IsOptional() + @IsBoolean() + mockCheckout?: boolean; + + @ApiPropertyOptional({ + description: 'Optional webhook URL to notify when report generation completes', + required: false, + }) + @IsOptional() + @IsUrl() + webhookUrl?: string; + + @ApiPropertyOptional({ + description: 'Whether to run the pentest in simulation mode', + required: false, + default: false, + }) + @IsOptional() + @IsBoolean() + testMode?: boolean; +} diff --git a/apps/api/src/security-penetration-tests/maced-client.ts b/apps/api/src/security-penetration-tests/maced-client.ts new file mode 100644 index 0000000000..efcf7353f4 --- /dev/null +++ b/apps/api/src/security-penetration-tests/maced-client.ts @@ -0,0 +1,275 @@ +import { + HttpException, + HttpStatus, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { z } from 'zod'; + +const macedPentestStatusSchema = z.enum([ + 'provisioning', + 'cloning', + 'running', + 'completed', + 'failed', + 'cancelled', +]); + +const macedPentestProgressSchema = z.object({ + status: macedPentestStatusSchema, + phase: z.string().nullable(), + agent: z.string().nullable(), + completedAgents: z.number().int(), + totalAgents: z.number().int(), + elapsedMs: z.number(), +}); + +const macedPentestRunSchema = z + .object({ + id: z.string().min(1), + sandboxId: z.string().min(1), + workflowId: z.string().min(1), + sessionId: z.string().min(1), + targetUrl: z.string().url(), + repoUrl: z.string().url().nullable().optional(), + status: macedPentestStatusSchema, + testMode: z.boolean().optional(), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), + error: z.string().nullable().optional(), + temporalUiUrl: z.string().url().nullable().optional(), + webhookUrl: z.string().url().nullable().optional(), + webhookToken: z.string().optional(), + userId: z.string().min(1), + organizationId: z.string().min(1), + checkoutMode: z.enum(['stripe', 'mock']).optional(), + checkoutUrl: z.string().url().optional(), + }) + .passthrough(); + +const macedPentestRunWithProgressSchema = macedPentestRunSchema.extend({ + progress: macedPentestProgressSchema, +}); + +const macedPentestRunListSchema = z.array(macedPentestRunSchema); + +const macedCreatePentestPayloadSchema = z + .object({ + targetUrl: z.string().url(), + repoUrl: z.string().url().optional(), + githubToken: z.string().optional(), + configYaml: z.string().optional(), + pipelineTesting: z.boolean().optional(), + testMode: z.boolean().optional(), + workspace: z.string().optional(), + webhookUrl: z.string().url().optional(), + mockCheckout: z.boolean().optional(), + }) + .strict(); + +export type MacedPentestStatus = z.infer; +export type MacedPentestProgress = z.infer; +export type MacedPentestRun = z.infer; +export type MacedPentestRunWithProgress = z.infer< + typeof macedPentestRunWithProgressSchema +>; +export type MacedCreatePentestPayload = z.infer< + typeof macedCreatePentestPayloadSchema +>; + +export class MacedClient { + private readonly logger = new Logger(MacedClient.name); + private readonly apiBaseUrl = + process.env.MACED_API_BASE_URL ?? 'https://api.maced.ai'; + private readonly apiKey = process.env.MACED_API_KEY; + + private get providerHeaders() { + if (!this.apiKey) { + this.logger.error('MACED_API_KEY is not configured'); + throw new InternalServerErrorException( + 'Maced API key not configured on server', + ); + } + + return { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + }; + } + + private async parseErrorPayload(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (!text) { + return `Request failed with status ${response.status}`; + } + + try { + const parsed = JSON.parse(text) as { error?: string; message?: string }; + return parsed.error ?? parsed.message ?? text; + } catch { + return text; + } + } + + private async request(path: string, init: RequestInit): Promise { + let response: Response; + try { + response = await fetch(`${this.apiBaseUrl}${path}`, { + ...init, + headers: { + ...this.providerHeaders, + ...init.headers, + }, + cache: 'no-store', + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + this.logger.error( + `Transport failure calling Maced endpoint ${path}`, + error instanceof Error ? error.message : String(error), + ); + throw new HttpException( + { error: 'Unable to reach penetration test provider' }, + HttpStatus.BAD_GATEWAY, + ); + } + + if (!response.ok) { + const error = await this.parseErrorPayload(response); + throw new HttpException( + { + error, + }, + response.status as HttpStatus, + ); + } + + return response; + } + + private parseValidatedJson( + body: string, + schema: z.ZodType, + context: string, + ): T { + let parsedBody: unknown; + try { + parsedBody = JSON.parse(body) as unknown; + } catch (error) { + this.logger.error( + `Unable to parse Maced JSON response (${context})`, + error instanceof Error ? error.message : String(error), + ); + throw new HttpException( + { error: 'Invalid response received from penetration test provider' }, + HttpStatus.BAD_GATEWAY, + ); + } + + const validated = schema.safeParse(parsedBody); + if (!validated.success) { + this.logger.error( + `Maced response schema validation failed (${context})`, + validated.error.message, + ); + throw new HttpException( + { error: 'Invalid response received from penetration test provider' }, + HttpStatus.BAD_GATEWAY, + ); + } + + return validated.data; + } + + private async requestJson( + path: string, + init: RequestInit, + schema: z.ZodType, + context: string, + ): Promise { + const response = await this.request(path, init); + const body = await response.text(); + if (!body) { + throw new HttpException( + { error: `Empty response while ${context}` }, + HttpStatus.BAD_GATEWAY, + ); + } + + return this.parseValidatedJson(body, schema, context); + } + + async listPentests(): Promise { + const response = await this.request('/v1/pentests', { method: 'GET' }); + const body = await response.text(); + if (!body) { + return []; + } + + return this.parseValidatedJson( + body, + macedPentestRunListSchema, + 'listing penetration tests', + ); + } + + async createPentest(payload: MacedCreatePentestPayload): Promise { + const validatedPayload = macedCreatePentestPayloadSchema.safeParse(payload); + if (!validatedPayload.success) { + this.logger.error( + 'Invalid create pentest payload', + validatedPayload.error.message, + ); + throw new HttpException( + { error: 'Invalid request payload for penetration test provider' }, + HttpStatus.BAD_REQUEST, + ); + } + + return this.requestJson( + '/v1/pentests', + { + method: 'POST', + body: JSON.stringify(validatedPayload.data), + }, + macedPentestRunSchema, + 'creating penetration test', + ); + } + + async getPentest(id: string): Promise { + return this.requestJson( + `/v1/pentests/${encodeURIComponent(id)}`, + { + method: 'GET', + }, + macedPentestRunWithProgressSchema, + `fetching penetration test ${id}`, + ); + } + + async getPentestProgress(id: string): Promise { + return this.requestJson( + `/v1/pentests/${encodeURIComponent(id)}/progress`, + { + method: 'GET', + }, + macedPentestProgressSchema, + `fetching penetration test progress ${id}`, + ); + } + + getPentestReportRaw(id: string): Promise { + return this.request(`/v1/pentests/${encodeURIComponent(id)}/report/raw`, { + method: 'GET', + }); + } + + getPentestReportPdf(id: string): Promise { + return this.request(`/v1/pentests/${encodeURIComponent(id)}/report/pdf`, { + method: 'GET', + }); + } +} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts new file mode 100644 index 0000000000..c1a7d90870 --- /dev/null +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.spec.ts @@ -0,0 +1,212 @@ +jest.mock('../auth/hybrid-auth.guard', () => ({ + HybridAuthGuard: class { + canActivate() { + return true; + } + }, +})); + +import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; +import type { SecurityPenetrationTestsService } from './security-penetration-tests.service'; +import type { Request as ExpressRequest } from 'express'; + +describe('SecurityPenetrationTestsController', () => { + const originalWebhookBase = process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL; + const createReportMock = jest.fn(); + const listReportsMock = jest.fn(); + const getReportMock = jest.fn(); + const getReportProgressMock = jest.fn(); + const getReportOutputMock = jest.fn(); + const getReportPdfMock = jest.fn(); + const handleWebhookMock = jest.fn(); + + const serviceMock: jest.Mocked = { + createReport: createReportMock, + listReports: listReportsMock, + getReport: getReportMock, + getReportProgress: getReportProgressMock, + getReportOutput: getReportOutputMock, + getReportPdf: getReportPdfMock, + handleWebhook: handleWebhookMock, + } as unknown as jest.Mocked; + + const controller = new SecurityPenetrationTestsController(serviceMock); + + beforeEach(() => { + jest.clearAllMocks(); + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = 'https://callback.example.com/webhook'; + }); + + afterAll(() => { + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = originalWebhookBase; + }); + + it('lists reports for the organization', async () => { + const expectedReports = [{ id: 'run_1', status: 'completed' }]; + listReportsMock.mockResolvedValueOnce(expectedReports); + + const response = await controller.list('org_123'); + + expect(listReportsMock).toHaveBeenCalledWith('org_123'); + expect(response).toEqual(expectedReports); + }); + + it('creates report with normalized webhook URL when missing in payload', async () => { + createReportMock.mockResolvedValueOnce({ + id: 'run_1', + status: 'provisioning', + }); + + await controller.create('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }); + + expect(createReportMock).toHaveBeenCalledWith('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + githubToken: undefined, + configYaml: undefined, + pipelineTesting: undefined, + workspace: undefined, + testMode: undefined, + mockCheckout: undefined, + webhookUrl: undefined, + }); + }); + + it('returns a report by id', async () => { + const expectedReport = { id: 'run_1', status: 'completed' }; + getReportMock.mockResolvedValueOnce(expectedReport); + + const response = await controller.getById('org_123', 'run_1'); + + expect(getReportMock).toHaveBeenCalledWith('org_123', 'run_1'); + expect(response).toEqual(expectedReport); + }); + + it('gets progress for a report', async () => { + const expectedProgress = { + status: 'running', + phase: 'scan', + agent: null, + completedAgents: 1, + totalAgents: 3, + elapsedMs: 1000, + }; + getReportProgressMock.mockResolvedValueOnce(expectedProgress); + + const response = await controller.getProgress('org_123', 'run_1'); + + expect(getReportProgressMock).toHaveBeenCalledWith('org_123', 'run_1'); + expect(response).toEqual(expectedProgress); + }); + + it('gets report output with response headers', async () => { + getReportOutputMock.mockResolvedValueOnce({ + buffer: Buffer.from('markdown report'), + contentType: 'text/markdown; charset=utf-8', + contentDisposition: 'inline; filename="run_1.md"', + }); + const responseMock = { set: jest.fn() }; + + const output = await controller.getReport('org_123', 'run_1', responseMock as never); + + expect(getReportOutputMock).toHaveBeenCalledWith('org_123', 'run_1'); + expect(responseMock.set).toHaveBeenCalledWith({ + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'no-store', + }); + expect(output).toBeDefined(); + }); + + it('gets pdf output and applies default attachment filename when missing', async () => { + getReportPdfMock.mockResolvedValueOnce({ + buffer: Buffer.from('pdf'), + contentType: 'application/pdf', + }); + const responseMock = { set: jest.fn() }; + + const output = await controller.getPdf('org_123', 'run_1', responseMock as never); + + expect(getReportPdfMock).toHaveBeenCalledWith('org_123', 'run_1'); + expect(responseMock.set).toHaveBeenCalledWith({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="penetration-test-run_1.pdf"', + 'Cache-Control': 'no-store', + }); + expect(output).toBeDefined(); + }); + + it('routes webhook payload through validation and service handler', async () => { + const requestMock = { + query: { + orgId: 'from-query', + }, + headers: {}, + } as unknown as ExpressRequest; + const webhookPayload = { id: 'run_2', status: 'completed' }; + handleWebhookMock.mockResolvedValueOnce({ + success: true, + organizationId: 'org_123', + }); + + await controller.handleWebhook(requestMock, webhookPayload); + + expect(handleWebhookMock).toHaveBeenCalledWith( + webhookPayload, + { + webhookToken: undefined, + eventId: undefined, + }, + ); + }); + + it('accepts first query value from array form in webhook extraction', async () => { + const requestMock = { + query: { + webhookToken: ['query-token', 'ignored'], + }, + headers: {}, + } as unknown as ExpressRequest; + const webhookPayload = { id: 'run_3', status: 'completed' }; + handleWebhookMock.mockResolvedValueOnce({ + success: true, + organizationId: 'org_123', + }); + + await controller.handleWebhook(requestMock, webhookPayload); + + expect(handleWebhookMock).toHaveBeenCalledWith( + webhookPayload, + { + webhookToken: 'query-token', + eventId: undefined, + }, + ); + }); + + it('passes webhook token and event id metadata into service handler', async () => { + const requestMock = { + query: { webhookToken: 'query-token' }, + headers: { + 'x-webhook-id': 'evt_123', + }, + } as unknown as ExpressRequest; + const webhookPayload = { id: 'run_4', status: 'completed' }; + handleWebhookMock.mockResolvedValueOnce({ + success: true, + organizationId: 'org_123', + }); + + await controller.handleWebhook(requestMock, webhookPayload); + + expect(handleWebhookMock).toHaveBeenCalledWith( + webhookPayload, + { + webhookToken: 'query-token', + eventId: 'evt_123', + }, + ); + }); +}); diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts new file mode 100644 index 0000000000..75c47cce64 --- /dev/null +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.controller.ts @@ -0,0 +1,242 @@ +import { + Controller, + Get, + HttpCode, + Body, + Param, + Post, + Req, + Res, + StreamableFile, + UseGuards, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiQuery, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; +import type { Response } from 'express'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; +import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; + +@ApiTags('Security Penetration Tests') +@Controller({ path: 'security-penetration-tests', version: '1' }) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class SecurityPenetrationTestsController { + constructor( + private readonly service: SecurityPenetrationTestsService, + ) {} + + @Get() + @UseGuards(HybridAuthGuard) + @ApiOperation({ + summary: 'List penetration test runs', + description: + 'Returns all penetration tests created for the organization.', + }) + @ApiResponse({ + status: 200, + description: 'Penetration tests returned', + }) + async list(@OrganizationId() organizationId: string) { + return this.service.listReports(organizationId); + } + + @Post() + @UseGuards(HybridAuthGuard) + @HttpCode(201) + @ApiOperation({ + summary: 'Create penetration test', + description: + 'Creates a new penetration test run and returns the run metadata.', + }) + @ApiResponse({ + status: 201, + description: 'Penetration test created', + }) + @ApiResponse({ + status: 400, + description: 'Invalid request payload', + }) + async create( + @OrganizationId() organizationId: string, + @Body() body: CreatePenetrationTestDto, + ) { + return this.service.createReport(organizationId, body); + } + + @Get(':id') + @UseGuards(HybridAuthGuard) + @ApiOperation({ + summary: 'Get penetration test status', + description: 'Returns a penetration test run with progress metadata.', + }) + @ApiResponse({ + status: 200, + description: 'Penetration test returned', + }) + @ApiResponse({ + status: 404, + description: 'Penetration test not found', + }) + async getById( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.service.getReport(organizationId, id); + } + + @Get(':id/progress') + @UseGuards(HybridAuthGuard) + @ApiOperation({ + summary: 'Get penetration test progress', + description: 'Returns detailed progress for an in-flight report run.', + }) + @ApiResponse({ + status: 200, + description: 'Progress returned', + }) + async getProgress(@OrganizationId() organizationId: string, @Param('id') id: string) { + return this.service.getReportProgress(organizationId, id); + } + + @Get(':id/report') + @UseGuards(HybridAuthGuard) + @ApiOperation({ + summary: 'Get penetration test output', + description: 'Returns the markdown report output for a completed run.', + }) + @ApiResponse({ + status: 200, + description: 'Markdown report output', + }) + async getReport( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ): Promise { + const output = await this.service.getReportOutput(organizationId, id); + + response.set({ + 'Content-Type': output.contentType, + 'Cache-Control': 'no-store', + }); + + return new StreamableFile(output.buffer); + } + + @Get(':id/pdf') + @UseGuards(HybridAuthGuard) + @ApiOperation({ + summary: 'Get penetration test PDF', + description: 'Returns the PDF version of a completed report.', + }) + @ApiResponse({ + status: 200, + description: 'PDF report artifact', + }) + async getPdf( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Res({ passthrough: true }) response: Response, + ): Promise { + const output = await this.service.getReportPdf(organizationId, id); + + response.set({ + 'Content-Type': output.contentType, + 'Content-Disposition': + output.contentDisposition ?? + `attachment; filename="penetration-test-${id}.pdf"`, + 'Cache-Control': 'no-store', + }); + + return new StreamableFile(output.buffer); + } + + @Post('webhook') + @ApiOperation({ + summary: 'Receive penetration test webhook events', + 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.', + }) + @ApiHeader({ + name: 'X-Webhook-Id', + description: + 'Optional provider event identifier used for idempotency detection.', + required: false, + }) + @ApiHeader({ + name: 'X-Webhook-Token', + description: + 'Optional webhook token header. Query param webhookToken is also accepted.', + required: false, + }) + @ApiQuery({ + name: 'webhookToken', + required: false, + description: + 'Per-job webhook token used for handshake validation when callbacks are sent to Comp.', + }) + @ApiResponse({ + status: 200, + description: 'Webhook handled', + }) + @ApiResponse({ + status: 400, + description: 'Invalid webhook payload', + }) + @HttpCode(200) + async handleWebhook( + @Req() request: Request, + @Body() body: Record, + ) { + const webhookToken = + this.extractStringFromQuery(request, 'webhookToken') ?? + this.extractStringFromHeader(request, 'x-webhook-token'); + const eventId = + this.extractStringFromHeader(request, 'x-webhook-id') ?? + this.extractStringFromHeader(request, 'x-request-id'); + + return this.service.handleWebhook(body, { + webhookToken, + eventId, + }); + } + + private extractStringFromQuery(request: Request, key: string): string | undefined { + const queryValue = request.query[key]; + if (Array.isArray(queryValue)) { + return this.extractStringFromQueryValue(queryValue[0]); + } + + return this.extractStringFromQueryValue(queryValue); + } + + private extractStringFromQueryValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; + } + + private extractStringFromHeader( + request: Request, + key: string, + ): string | undefined { + const headerValue = request.headers[key.toLowerCase()]; + + if (Array.isArray(headerValue)) { + return typeof headerValue[0] === 'string' ? headerValue[0] : undefined; + } + + return typeof headerValue === 'string' ? headerValue : undefined; + } +} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts new file mode 100644 index 0000000000..a0770b22fd --- /dev/null +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { SecurityPenetrationTestsController } from './security-penetration-tests.controller'; +import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; + +@Module({ + imports: [AuthModule], + controllers: [SecurityPenetrationTestsController], + providers: [SecurityPenetrationTestsService], +}) +export class SecurityPenetrationTestsModule {} diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts new file mode 100644 index 0000000000..ee27afbb0c --- /dev/null +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.spec.ts @@ -0,0 +1,927 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { createHash } from 'node:crypto'; +import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; +import { SecurityPenetrationTestsService } from './security-penetration-tests.service'; + +jest.mock('@trycompai/db', () => ({ + db: { + securityPenetrationTestRun: { + upsert: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + }, + secret: { + upsert: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }, +})); + +type MockDb = { + securityPenetrationTestRun: { + upsert: jest.Mock; + findUnique: jest.Mock; + findMany: jest.Mock; + }; + secret: { + upsert: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; +}; + +describe('SecurityPenetrationTestsService', () => { + const originalFetch = global.fetch; + const originalMacedApiKey = process.env.MACED_API_KEY; + const originalWebhookBase = process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL; + const defaultWebhookToken = 'test-webhook-token'; + const defaultWebhookTokenHash = createHash('sha256') + .update(defaultWebhookToken) + .digest('hex'); + const fetchMock = jest.fn() as jest.Mock; + const mockedDb = db as unknown as MockDb; + let service: SecurityPenetrationTestsService; + + beforeAll(() => { + process.env.MACED_API_KEY = 'test-maced-api-key'; + }); + + afterAll(() => { + process.env.MACED_API_KEY = originalMacedApiKey; + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = originalWebhookBase; + if (originalFetch) { + global.fetch = originalFetch; + } + }); + + beforeEach(() => { + process.env.MACED_API_KEY = 'test-maced-api-key'; + service = new SecurityPenetrationTestsService(); + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + mockedDb.securityPenetrationTestRun.upsert.mockResolvedValue({}); + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValue({ + organizationId: 'org_123', + }); + mockedDb.securityPenetrationTestRun.findMany.mockResolvedValue([ + { providerRunId: 'run_123' }, + ]); + mockedDb.secret.upsert.mockResolvedValue({}); + mockedDb.secret.findUnique.mockResolvedValue({ + id: 'sec_default', + value: JSON.stringify({ + tokenHash: defaultWebhookTokenHash, + createdAt: '2026-03-01T00:00:00.000Z', + }), + }); + mockedDb.secret.update.mockResolvedValue({}); + jest.clearAllMocks(); + }); + + it('lists reports with organization context', async () => { + const expectedPayload = [ + { + id: 'run_123', + status: 'completed', + }, + ]; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(expectedPayload), { status: 200 }), + ); + + const result = await service.listReports('org_123'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.maced.ai/v1/pentests', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-api-key': 'test-maced-api-key', + }), + }), + ); + expect(result).toEqual(expectedPayload); + }); + + it('creates report payload with resolved webhook URL', async () => { + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = 'https://api.trycomp.ai/webhook'; + const expectedPayload = { + id: 'run_456', + status: 'provisioning', + webhookToken: 'provider-issued-token', + }; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(expectedPayload), { status: 200 }), + ); + + const payload: CreatePenetrationTestDto = { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + testMode: true, + }; + + await service.createReport('org_123', payload); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe( + 'https://api.trycomp.ai/webhook/v1/security-penetration-tests/webhook', + ); + expect(requestBody.targetUrl).toBe(payload.targetUrl); + expect(requestBody.repoUrl).toBe(payload.repoUrl); + expect(requestBody.testMode).toBe(true); + expect(requestBody).not.toHaveProperty('webhookUrl', 'https://report-callback.example.com/webhook'); + expect(mockedDb.secret.upsert).toHaveBeenCalledTimes(1); + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledTimes(1); + }); + + it('uses production webhook default when webhook URL is not provided or configured', async () => { + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = ''; + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_default_webhook', + status: 'provisioning', + webhookToken: 'provider-issued-token', + }), + { status: 200 }, + ), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe( + 'https://api.trycomp.ai/v1/security-penetration-tests/webhook', + ); + }); + + it('returns 502 when provider create response omits webhook token for Comp webhook callbacks', async () => { + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = + 'https://api.trycomp.ai/webhook'; + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_missing_token', + status: 'provisioning', + }), + { status: 200 }, + ), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: + 'Penetration test was created at provider but webhook handshake token was missing', + }, + }), + ); + + expect(mockedDb.secret.upsert).not.toHaveBeenCalled(); + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('returns 502 when webhook handshake persistence fails', async () => { + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = + 'https://api.trycomp.ai/webhook'; + mockedDb.secret.upsert.mockRejectedValue(new Error('db unavailable')); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_handshake_retry', + status: 'provisioning', + webhookToken: 'provider-issued-token', + }), + { status: 200 }, + ), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: + 'Penetration test was created at provider but webhook handshake could not be persisted', + }, + }), + ); + + expect(mockedDb.secret.upsert).toHaveBeenCalledTimes(3); + expect(mockedDb.securityPenetrationTestRun.upsert).not.toHaveBeenCalled(); + }); + + it('persists ownership using create response id', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_from_id_field', + status: 'provisioning', + }), + { status: 200 }, + ), + ); + + const result = await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }); + + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + providerRunId: 'run_from_id_field', + }, + }), + ); + expect(result.id).toBe('run_from_id_field'); + }); + + it('returns 502 when ownership persistence fails', async () => { + mockedDb.securityPenetrationTestRun.upsert.mockRejectedValue( + new Error('db unavailable'), + ); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_ownership_retry', + status: 'provisioning', + }), + { status: 200 }, + ), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: + 'Penetration test was created at provider but ownership mapping could not be persisted', + }, + }), + ); + + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledTimes(3); + }); + + it('rejects relative webhook URLs', async () => { + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl: '/v1/security-penetration-tests/webhook-route', + }), + ).rejects.toEqual( + expect.objectContaining({ + response: { + message: 'webhookUrl must be a valid absolute URL', + }, + }), + ); + }); + + it('handles non-json create response as mapped 502', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unexpected payload', { status: 200 }), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Invalid response received from penetration test provider', + }, + }), + ); + }); + + it('returns empty list for empty payload', async () => { + fetchMock.mockResolvedValueOnce( + new Response('', { status: 200 }), + ); + + await expect(service.listReports('org_123')).resolves.toEqual([]); + }); + + it('maps invalid list payload to bad gateway', async () => { + fetchMock.mockResolvedValueOnce( + new Response('not-json', { status: 200 }), + ); + + await expect(service.listReports('org_123')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Invalid response received from penetration test provider', + }, + }), + ); + }); + + it('normalizes unversioned webhook route to canonical v1 webhook route', async () => { + const expectedPayload = { + id: 'run_789', + status: 'provisioning', + webhookToken: 'provider-token', + }; + const webhookUrl = 'https://app.company.test/security-penetration-tests/webhook'; + + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL = webhookUrl; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(expectedPayload), { status: 200 }), + ); + + const payload: CreatePenetrationTestDto = { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl, + }; + + await service.createReport('org_123', payload); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe('https://app.company.test/v1/security-penetration-tests/webhook'); + }); + + it('allows third-party webhook URLs without requiring provider webhook token', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_external_callback', + status: 'provisioning', + }), + { status: 200 }, + ), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl: 'https://external-webhook.example.com/callback', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'run_external_callback', + }), + ); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe( + 'https://external-webhook.example.com/callback/v1/security-penetration-tests/webhook', + ); + expect(mockedDb.secret.upsert).not.toHaveBeenCalled(); + expect(mockedDb.securityPenetrationTestRun.upsert).toHaveBeenCalledTimes(1); + }); + + it('normalizes legacy /api/security/penetration-tests/webhook route to canonical v1 route', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_api_legacy', + status: 'provisioning', + webhookToken: 'provider-token', + }), + { status: 200 }, + ), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl: + 'https://app.company.test/api/security/penetration-tests/webhook?foo=bar', + }); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe( + 'https://app.company.test/v1/security-penetration-tests/webhook?foo=bar', + ); + }); + + it('keeps provided webhook route plus query params', async () => { + const expectedPayload = { + id: 'run_qp', + status: 'provisioning', + }; + + const webhookUrl = 'https://app.company.test/v1/security-penetration-tests/webhook?foo=bar'; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(expectedPayload), { status: 200 }), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl, + }); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe('https://app.company.test/v1/security-penetration-tests/webhook?foo=bar'); + }); + + it('supports absolute webhook URLs that require appending the expected endpoint', async () => { + const expectedPayload = { + id: 'run_101', + status: 'provisioning', + }; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(expectedPayload), { status: 200 }), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl: 'https://callback.example.com/hook', + }); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe('https://callback.example.com/hook/v1/security-penetration-tests/webhook'); + }); + + it('strips webhookToken query parameter before forwarding webhook URL to provider', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_strip_token', + status: 'provisioning', + webhookToken: 'provider-token', + }), + { status: 200 }, + ), + ); + + await service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + webhookUrl: + 'https://callback.example.com/hook/v1/security-penetration-tests/webhook?foo=bar&webhookToken=user-token', + }); + + const [, options] = fetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body as string) as Record; + + expect(requestBody.webhookUrl).toBe( + 'https://callback.example.com/hook/v1/security-penetration-tests/webhook?foo=bar', + ); + }); + + it('throws HttpException when report payload is invalid JSON', async () => { + fetchMock.mockResolvedValueOnce( + new Response('unexpected payload', { status: 200 }), + ); + + await expect( + service.createReport('org_123', { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow(HttpException); + }); + + it('reads webhook status and report id from provider payload', () => { + const webhookResult = service.handleWebhook( + { + id: 'run_webhook', + status: 'completed', + }, + { + webhookToken: defaultWebhookToken, + }, + ); + + return expect(webhookResult).resolves.toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_webhook', + status: 'completed', + eventType: 'status', + }); + }); + + it('validates persisted per-job webhook token and records event metadata', async () => { + mockedDb.secret.findUnique.mockResolvedValueOnce({ + id: 'sec_1', + value: JSON.stringify({ + tokenHash: createHash('sha256').update('job-token').digest('hex'), + createdAt: '2026-03-01T00:00:00.000Z', + }), + }); + + const webhookResult = await service.handleWebhook( + { + id: 'run_webhook', + status: 'completed', + }, + { + webhookToken: 'job-token', + eventId: 'evt_1', + }, + ); + + expect(webhookResult).toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_webhook', + status: 'completed', + eventType: 'status', + }); + expect(mockedDb.secret.findUnique).toHaveBeenCalledWith({ + where: { + organizationId_name: { + organizationId: 'org_123', + name: 'security_penetration_test_webhook_run_webhook', + }, + }, + select: { + id: true, + value: true, + }, + }); + expect(mockedDb.secret.update).toHaveBeenCalledTimes(1); + }); + + it('marks webhook event as duplicate when event id repeats', async () => { + mockedDb.secret.findUnique.mockResolvedValueOnce({ + id: 'sec_2', + value: JSON.stringify({ + tokenHash: createHash('sha256').update('job-token').digest('hex'), + createdAt: '2026-03-01T00:00:00.000Z', + lastEventId: 'evt_duplicate', + }), + }); + + const webhookResult = await service.handleWebhook( + { + id: 'run_webhook', + status: 'completed', + }, + { + webhookToken: 'job-token', + eventId: 'evt_duplicate', + }, + ); + + expect(webhookResult).toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_webhook', + status: 'completed', + eventType: 'status', + duplicate: true, + }); + }); + + it('rejects webhook when run ownership mapping does not exist', async () => { + mockedDb.securityPenetrationTestRun.findUnique.mockResolvedValueOnce(null); + + await expect( + service.handleWebhook( + { + id: 'run_missing', + status: 'completed', + }, + { + webhookToken: defaultWebhookToken, + }, + ), + ).rejects.toThrow(HttpException); + }); + + it('uses reportStatus when id status fields are absent in webhook payload', () => { + const webhookResult = service.handleWebhook( + { + id: 'run_from_run_id', + reportStatus: 'queued', + }, + { + webhookToken: defaultWebhookToken, + }, + ); + + return expect(webhookResult).resolves.toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_from_run_id', + status: 'queued', + eventType: 'status', + }); + }); + + it('maps Maced completion webhook payload to completed status with report summary', () => { + const webhookResult = service.handleWebhook( + { + id: 'run_completed', + report: { + markdown: '# Penetration test', + costUsd: 49.11, + durationMs: 265000, + agentCount: 4, + }, + }, + { + webhookToken: defaultWebhookToken, + }, + ); + + return expect(webhookResult).resolves.toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_completed', + status: 'completed', + eventType: 'completed', + report: { + costUsd: 49.11, + durationMs: 265000, + agentCount: 4, + hasMarkdown: true, + }, + }); + }); + + it('maps Maced failed webhook payload to failed status with failure details', () => { + const webhookResult = service.handleWebhook( + { + id: 'run_failed', + error: 'Workflow exited early', + failedAt: '2026-02-28T21:30:00Z', + }, + { + webhookToken: defaultWebhookToken, + }, + ); + + return expect(webhookResult).resolves.toEqual({ + success: true, + organizationId: 'org_123', + reportId: 'run_failed', + status: 'failed', + eventType: 'failed', + failure: { + error: 'Workflow exited early', + failedAt: '2026-02-28T21:30:00Z', + }, + }); + }); + + it('throws when MACED API key is missing', async () => { + process.env.MACED_API_KEY = ''; + const serviceWithoutKey = new SecurityPenetrationTestsService(); + + await expect(serviceWithoutKey.listReports('org_123')).rejects.toThrow( + 'Maced API key not configured on server', + ); + }); + + it('fetches report output as binary payload', async () => { + const fixtureContent = 'markdown report body'; + const fixtureBuffer = new TextEncoder().encode(fixtureContent); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_output', + organizationId: 'org_123', + status: 'completed', + }), + { status: 200 }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response(fixtureBuffer, { + status: 200, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + }, + }), + ); + + const output = await service.getReportOutput('org_123', 'run_output'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.maced.ai/v1/pentests/run_output/report/raw', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-api-key': 'test-maced-api-key', + }), + }), + ); + expect(output.buffer).toEqual(Buffer.from(fixtureBuffer)); + expect(output.contentType).toBe('text/markdown; charset=utf-8'); + }); + + it('falls back to markdown content type when response omits content-type', async () => { + const fixtureContent = 'raw report'; + const fixtureBuffer = new TextEncoder().encode(fixtureContent); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_output_no_type', + organizationId: 'org_123', + status: 'completed', + }), + { status: 200 }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response(fixtureBuffer, { + status: 200, + }), + ); + + const output = await service.getReportOutput('org_123', 'run_output_no_type'); + + expect(output.contentType).toBe('text/markdown; charset=utf-8'); + expect(output.contentDisposition).toBeNull(); + expect(output.buffer).toEqual(Buffer.from(fixtureBuffer)); + }); + + it('gets report data by id', async () => { + const fixtureReport = { id: 'run_123', status: 'completed' }; + + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify(fixtureReport), { status: 200 }), + ); + + const report = await service.getReport('org_123', 'run_123'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.maced.ai/v1/pentests/run_123', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-api-key': 'test-maced-api-key', + }), + }), + ); + expect(report).toEqual(fixtureReport); + }); + + it('maps invalid get report response to bad gateway', async () => { + fetchMock.mockResolvedValueOnce( + new Response('invalid-json', { status: 200 }), + ); + + await expect(service.getReport('org_123', 'run_123')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Invalid response received from penetration test provider', + }, + }), + ); + }); + + it('maps empty get report response to bad gateway', async () => { + fetchMock.mockResolvedValueOnce( + new Response('', { status: 200 }), + ); + + await expect(service.getReport('org_123', 'run_123')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Empty response while fetching penetration test', + }, + }), + ); + }); + + it('maps empty get progress response to bad gateway', async () => { + fetchMock.mockResolvedValueOnce( + new Response('', { status: 200 }), + ); + + await expect(service.getReportProgress('org_123', 'run_123')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Empty response while fetching penetration test progress', + }, + }), + ); + }); + + it('maps invalid report progress payload to bad gateway', async () => { + fetchMock.mockResolvedValueOnce( + new Response('nope', { status: 200 }), + ); + + await expect(service.getReportProgress('org_123', 'run_123')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_GATEWAY, + response: { + error: 'Invalid response received from penetration test provider', + }, + }), + ); + }); + + it('generates fallback PDF file details when disposition is missing', async () => { + const fixtureContent = 'pdf report content'; + const fixtureBuffer = new TextEncoder().encode(fixtureContent); + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 'run_pdf', + organizationId: 'org_123', + status: 'completed', + }), + { status: 200 }, + ), + ); + fetchMock.mockResolvedValueOnce( + new Response(fixtureBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + }, + }), + ); + + const output = await service.getReportPdf('org_123', 'run_pdf'); + + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://api.maced.ai/v1/pentests/run_pdf/report/pdf', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-api-key': 'test-maced-api-key', + }), + }), + ); + expect(output.buffer).toEqual(Buffer.from(fixtureBuffer)); + expect(output.contentType).toBe('application/pdf'); + expect(output.contentDisposition).toBe('attachment; filename="penetration-test-run_pdf.pdf"'); + }); + + it('throws a mapped HttpException for failed provider calls', async () => { + fetchMock.mockResolvedValueOnce( + new Response('{"error":"server error"}', { + status: HttpStatus.BAD_REQUEST, + }), + ); + + await expect(service.getReport('org_123', 'missing')).rejects.toEqual( + expect.objectContaining({ + status: HttpStatus.BAD_REQUEST, + response: { + error: 'server error', + }, + }), + ); + }); +}); diff --git a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts new file mode 100644 index 0000000000..e96d00525a --- /dev/null +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts @@ -0,0 +1,782 @@ +import { + BadRequestException, + ForbiddenException, + HttpException, + HttpStatus, + Injectable, + Logger, +} from '@nestjs/common'; +import { db } from '@trycompai/db'; +import { createHash, timingSafeEqual } from 'node:crypto'; + +import type { CreatePenetrationTestDto } from './dto/create-penetration-test.dto'; +import { MacedClient, type MacedPentestProgress } from './maced-client'; + +export type PentestReportStatus = + | 'provisioning' + | 'cloning' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export type PentestProgress = MacedPentestProgress; + +export interface SecurityPenetrationTest { + id: string; + sandboxId: string; + workflowId: string; + sessionId: string; + targetUrl: string; + repoUrl: string | null; + status: PentestReportStatus; + testMode?: boolean | null; + createdAt: string; + updatedAt: string; + error?: string | null; + temporalUiUrl?: string | null; + webhookUrl?: string | null; + webhookToken?: string | null; + userId: string; + organizationId: string; + progress?: PentestProgress; +} + +export interface BinaryArtifact { + buffer: Buffer; + contentType: string; + contentDisposition?: string | null; +} + +interface PentestCompletedWebhookPayload { + id: string; + report: { + markdown: string; + costUsd: number; + durationMs: number; + agentCount: number; + }; +} + +interface PentestFailedWebhookPayload { + id: string; + error: string; + failedAt: string; +} + +type WebhookEventType = 'status' | 'completed' | 'failed'; + +interface WebhookRequestMetadata { + webhookToken?: string; + eventId?: string; +} + +interface PersistedWebhookHandshake { + tokenHash: string; + createdAt: string; + lastEventId?: string; + lastPayloadHash?: string; + lastWebhookAt?: string; +} + +@Injectable() +export class SecurityPenetrationTestsService { + private readonly logger = new Logger(SecurityPenetrationTestsService.name); + private readonly macedClient = new MacedClient(); + private readonly canonicalWebhookPath = '/v1/security-penetration-tests/webhook'; + private readonly defaultWebhookBaseUrl = 'https://api.trycomp.ai'; + private readonly defaultCompWebhookHosts = new Set([ + 'api.trycomp.ai', + 'api.staging.trycomp.ai', + 'localhost:3333', + ]); + + private get defaultWebhookBase() { + return ( + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL?.trim() || + this.defaultWebhookBaseUrl + ); + } + + async listReports(organizationId: string): Promise { + const ownedRunIds = await this.listOwnedRunIds(organizationId); + if (ownedRunIds.size === 0) { + return []; + } + + const reports = await this.macedClient.listPentests(); + + return reports.filter((report) => { + return ownedRunIds.has(report.id); + }) as SecurityPenetrationTest[]; + } + + async createReport( + organizationId: string, + payload: CreatePenetrationTestDto, + ): Promise { + const resolvedWebhookUrl = this.resolveWebhookUrl(payload.webhookUrl); + + const sanitizedPayload = { + targetUrl: payload.targetUrl, + repoUrl: payload.repoUrl, + githubToken: payload.githubToken, + configYaml: payload.configYaml, + pipelineTesting: payload.pipelineTesting, + testMode: payload.testMode, + workspace: payload.workspace, + mockCheckout: payload.mockCheckout, + webhookUrl: resolvedWebhookUrl, + }; + + const createdReport = await this.macedClient.createPentest(sanitizedPayload); + + const providerRunId = createdReport.id; + + if (!providerRunId) { + throw new HttpException( + { error: 'Create response missing report identifier' }, + HttpStatus.BAD_GATEWAY, + ); + } + const webhookToken = createdReport.webhookToken; + + if ( + resolvedWebhookUrl && + this.isCompWebhookUrl(resolvedWebhookUrl) && + !webhookToken + ) { + throw new HttpException( + { + error: + 'Penetration test was created at provider but webhook handshake token was missing', + }, + HttpStatus.BAD_GATEWAY, + ); + } + + if ( + resolvedWebhookUrl && + this.isCompWebhookUrl(resolvedWebhookUrl) && + webhookToken + ) { + const handshakePersisted = await this.persistWebhookHandshakeWithRetry( + organizationId, + providerRunId, + webhookToken, + ); + if (!handshakePersisted) { + throw new HttpException( + { + error: + 'Penetration test was created at provider but webhook handshake could not be persisted', + }, + HttpStatus.BAD_GATEWAY, + ); + } + } + + const ownershipPersisted = await this.persistRunOwnershipWithRetry( + organizationId, + providerRunId, + ); + if (!ownershipPersisted) { + throw new HttpException( + { + error: + 'Penetration test was created at provider but ownership mapping could not be persisted', + }, + HttpStatus.BAD_GATEWAY, + ); + } + + return createdReport as SecurityPenetrationTest; + } + + async getReport(organizationId: string, id: string): Promise { + await this.assertRunOwnership(organizationId, id); + const report = await this.macedClient.getPentest(id); + return report as SecurityPenetrationTest; + } + + async getReportProgress( + organizationId: string, + id: string, + ): Promise { + await this.assertRunOwnership(organizationId, id); + return this.macedClient.getPentestProgress(id); + } + + async getReportOutput(organizationId: string, id: string): Promise { + await this.getReport(organizationId, id); + + const response = await this.macedClient.getPentestReportRaw(id); + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get('Content-Type') || 'text/markdown; charset=utf-8', + contentDisposition: response.headers.get('Content-Disposition'), + }; + } + + async getReportPdf(organizationId: string, id: string): Promise { + await this.getReport(organizationId, id); + + const response = await this.macedClient.getPentestReportPdf(id); + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get('Content-Type') || 'application/pdf', + contentDisposition: + response.headers.get('Content-Disposition') || + `attachment; filename="penetration-test-${id}.pdf"`, + }; + } + + async handleWebhook( + payload: unknown, + metadata: WebhookRequestMetadata = {}, + ): Promise<{ + success: true; + organizationId: string; + reportId?: string; + status?: string; + eventType: WebhookEventType; + duplicate?: true; + report?: { + costUsd: number; + durationMs: number; + agentCount: number; + hasMarkdown: true; + }; + failure?: { + error: string; + failedAt: string; + }; + }> { + if (!this.isRecord(payload)) { + throw new BadRequestException('Invalid webhook payload'); + } + + const completedEvent = this.extractCompletedWebhookPayload(payload); + const failedEvent = this.extractFailedWebhookPayload(payload); + + const payloadReportId = + completedEvent?.id ?? + failedEvent?.id ?? + this.extractStringField(payload, 'id'); + + if (!payloadReportId) { + throw new BadRequestException('Webhook payload must include a report id'); + } + + const organizationId = await this.resolveOrganizationForRun(payloadReportId); + + const duplicate = await this.verifyAndRecordWebhookHandshake({ + organizationId, + reportId: payloadReportId, + payload, + webhookToken: metadata.webhookToken, + eventId: metadata.eventId, + }); + + const payloadStatus = + this.extractStringField(payload, 'status') || + this.extractStringField(payload, 'reportStatus') || + (completedEvent ? 'completed' : undefined) || + (failedEvent ? 'failed' : undefined); + + const eventType: WebhookEventType = completedEvent ? 'completed' : failedEvent ? 'failed' : 'status'; + + this.logger.log( + `[Webhook] Received penetration test ${eventType} event for org=${organizationId}${payloadReportId ? ` run=${payloadReportId}` : ''} status=${payloadStatus ?? 'unknown'}`, + ); + + return { + success: true, + organizationId, + eventType, + ...(payloadReportId ? { reportId: payloadReportId } : {}), + ...(payloadStatus ? { status: payloadStatus } : {}), + ...(duplicate ? ({ duplicate: true } as const) : {}), + ...(completedEvent + ? { + report: { + costUsd: completedEvent.report.costUsd, + durationMs: completedEvent.report.durationMs, + agentCount: completedEvent.report.agentCount, + hasMarkdown: true as const, + }, + } + : {}), + ...(failedEvent + ? { + failure: { + error: failedEvent.error, + failedAt: failedEvent.failedAt, + }, + } + : {}), + }; + } + + private trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 1 && value.charCodeAt(end - 1) === 47) { + end -= 1; + } + + return value.slice(0, end); + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private normalizeWebhookPath(path: string): string { + const normalizedPath = this.trimTrailingSlashes(path); + if (normalizedPath.endsWith(this.canonicalWebhookPath)) { + return normalizedPath; + } + + const legacySuffixes = [ + '/security-penetration-tests/webhook', + '/api/security/penetration-tests/webhook', + ] as const; + + for (const suffix of legacySuffixes) { + if (normalizedPath.endsWith(suffix)) { + const basePath = normalizedPath.slice(0, normalizedPath.length - suffix.length); + return basePath + ? `${basePath}${this.canonicalWebhookPath}` + : this.canonicalWebhookPath; + } + } + + if (normalizedPath === '/') { + return this.canonicalWebhookPath; + } + + return `${normalizedPath}${this.canonicalWebhookPath}`; + } + + private isWebhookPath(path: string): boolean { + return path.endsWith(this.canonicalWebhookPath); + } + + private resolveWebhookUrl( + providedUrl?: string, + ): string | undefined { + const baseUrl = providedUrl?.trim() || this.defaultWebhookBase; + if (!baseUrl) { + return undefined; + } + + let webhookUrl: URL; + try { + webhookUrl = new URL(baseUrl); + } catch { + throw new BadRequestException('webhookUrl must be a valid absolute URL'); + } + webhookUrl.pathname = this.normalizeWebhookPath(webhookUrl.pathname); + webhookUrl.searchParams.delete('webhookToken'); + + return webhookUrl.toString(); + } + + private extractStringField( + payload: unknown, + key: string, + ): string | undefined { + if (!this.isRecord(payload)) { + return undefined; + } + + const value = payload[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + } + + private extractNumberField( + payload: unknown, + key: string, + ): number | undefined { + if (!this.isRecord(payload)) { + return undefined; + } + + const value = payload[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + } + + private extractCompletedWebhookPayload( + payload: unknown, + ): PentestCompletedWebhookPayload | null { + if (!this.isRecord(payload)) { + return null; + } + + const reportId = this.extractStringField(payload, 'id'); + const reportValue = payload.report; + const isReportRecord = this.isRecord(reportValue); + + if (!reportId || !isReportRecord) { + return null; + } + + const reportRecord = reportValue as Record; + const markdown = this.extractStringField(reportRecord, 'markdown'); + const costUsd = this.extractNumberField(reportRecord, 'costUsd'); + const durationMs = this.extractNumberField(reportRecord, 'durationMs'); + const agentCount = this.extractNumberField(reportRecord, 'agentCount'); + + if ( + !markdown || + costUsd === undefined || + durationMs === undefined || + agentCount === undefined || + !Number.isInteger(agentCount) + ) { + return null; + } + + return { + id: reportId, + report: { + markdown, + costUsd, + durationMs, + agentCount, + }, + }; + } + + private extractFailedWebhookPayload( + payload: unknown, + ): PentestFailedWebhookPayload | null { + if (!this.isRecord(payload)) { + return null; + } + + const reportId = this.extractStringField(payload, 'id'); + const error = this.extractStringField(payload, 'error'); + const failedAt = this.extractStringField(payload, 'failedAt'); + + if (!reportId || !error || !failedAt) { + return null; + } + + return { + id: reportId, + error, + failedAt, + }; + } + + private hashValue(input: string): string { + return createHash('sha256').update(input).digest('hex'); + } + + private hashesEqual(a: string, b: string): boolean { + const aBuffer = Buffer.from(a, 'hex'); + const bBuffer = Buffer.from(b, 'hex'); + + if (aBuffer.length !== bBuffer.length) { + return false; + } + + return timingSafeEqual(aBuffer, bBuffer); + } + + private webhookHandshakeSecretName(reportId: string): string { + return `security_penetration_test_webhook_${reportId}`; + } + + private async persistRunOwnership( + organizationId: string, + reportId: string, + ): Promise { + await db.securityPenetrationTestRun.upsert({ + where: { + providerRunId: reportId, + }, + create: { + organizationId, + providerRunId: reportId, + }, + update: { + organizationId, + }, + }); + } + + private async persistRunOwnershipWithRetry( + organizationId: string, + reportId: string, + ): Promise { + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await this.persistRunOwnership(organizationId, reportId); + return true; + } catch (error) { + this.logger.error( + `Unable to persist ownership marker for report ${reportId} (attempt ${attempt}/3)`, + error instanceof Error ? error.message : String(error), + ); + } + } + + return false; + } + + private async assertRunOwnership( + organizationId: string, + reportId: string, + ): Promise { + const ownerOrganizationId = await this.resolveOrganizationForRun( + reportId, + new HttpException( + { error: 'Report not found' }, + HttpStatus.NOT_FOUND, + ), + ); + + if (ownerOrganizationId !== organizationId) { + throw new HttpException( + { error: 'Report not found' }, + HttpStatus.NOT_FOUND, + ); + } + } + + private async resolveOrganizationForRun( + reportId: string, + notFoundError: Error = new ForbiddenException('Run ownership mapping not found'), + ): Promise { + const marker = await db.securityPenetrationTestRun.findUnique({ + where: { + providerRunId: reportId, + }, + select: { + organizationId: true, + }, + }); + + if (!marker) { + throw notFoundError; + } + + return marker.organizationId; + } + + private async listOwnedRunIds(organizationId: string): Promise> { + const markers = (await db.securityPenetrationTestRun.findMany({ + where: { + organizationId, + }, + select: { + providerRunId: true, + }, + })) ?? []; + + return new Set(markers.map(({ providerRunId }) => providerRunId)); + } + + private isCompWebhookUrl(value: string): boolean { + try { + const parsed = new URL(value); + const normalizedPath = this.trimTrailingSlashes(parsed.pathname); + if (!this.isWebhookPath(normalizedPath)) { + return false; + } + + return this.trustedCompWebhookHosts().has(parsed.host.toLowerCase()); + } catch { + return false; + } + } + + private trustedCompWebhookHosts(): Set { + const hosts = new Set(this.defaultCompWebhookHosts); + const configuredUrls = [ + process.env.SECURITY_PENETRATION_TESTS_WEBHOOK_URL, + process.env.BASE_URL, + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL, + ]; + + for (const configuredUrl of configuredUrls) { + const candidate = configuredUrl?.trim(); + if (!candidate) { + continue; + } + + try { + hosts.add(new URL(candidate).host.toLowerCase()); + } catch { + this.logger.warn(`Ignoring invalid trusted webhook host URL: ${candidate}`); + } + } + + return hosts; + } + + private parseWebhookHandshake( + rawValue: string, + ): PersistedWebhookHandshake | null { + try { + const parsed = JSON.parse(rawValue) as Record; + const tokenHash = + typeof parsed.tokenHash === 'string' && + parsed.tokenHash.trim().length > 0 + ? parsed.tokenHash.trim() + : undefined; + const createdAt = + typeof parsed.createdAt === 'string' && + parsed.createdAt.trim().length > 0 + ? parsed.createdAt.trim() + : undefined; + + if (!tokenHash || !createdAt) { + return null; + } + + return { + tokenHash, + createdAt, + ...(typeof parsed.lastEventId === 'string' + ? { lastEventId: parsed.lastEventId } + : {}), + ...(typeof parsed.lastPayloadHash === 'string' + ? { lastPayloadHash: parsed.lastPayloadHash } + : {}), + ...(typeof parsed.lastWebhookAt === 'string' + ? { lastWebhookAt: parsed.lastWebhookAt } + : {}), + }; + } catch { + return null; + } + } + + private async persistWebhookHandshake( + organizationId: string, + reportId: string, + webhookToken: string, + ): Promise { + const handshakeState: PersistedWebhookHandshake = { + tokenHash: this.hashValue(webhookToken), + createdAt: new Date().toISOString(), + }; + + await db.secret.upsert({ + where: { + organizationId_name: { + organizationId, + name: this.webhookHandshakeSecretName(reportId), + }, + }, + create: { + organizationId, + name: this.webhookHandshakeSecretName(reportId), + category: 'webhook', + description: 'Maced penetration test webhook handshake', + value: JSON.stringify(handshakeState), + }, + update: { + category: 'webhook', + description: 'Maced penetration test webhook handshake', + value: JSON.stringify(handshakeState), + lastUsedAt: null, + }, + }); + } + + private async persistWebhookHandshakeWithRetry( + organizationId: string, + reportId: string, + webhookToken: string, + ): Promise { + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await this.persistWebhookHandshake(organizationId, reportId, webhookToken); + return true; + } catch (error) { + this.logger.error( + `Unable to persist webhook handshake for report ${reportId} (attempt ${attempt}/3)`, + error instanceof Error ? error.message : String(error), + ); + } + } + + return false; + } + + private async verifyAndRecordWebhookHandshake(params: { + organizationId: string; + reportId: string; + payload: Record; + webhookToken?: string; + eventId?: string; + }): Promise { + const storedHandshake = await db.secret.findUnique({ + where: { + organizationId_name: { + organizationId: params.organizationId, + name: this.webhookHandshakeSecretName(params.reportId), + }, + }, + select: { + id: true, + value: true, + }, + }); + + if (!storedHandshake) { + throw new ForbiddenException('Webhook handshake not found for report'); + } + + const handshakeState = this.parseWebhookHandshake(storedHandshake.value); + if (!handshakeState) { + throw new ForbiddenException('Invalid webhook handshake state'); + } + + if (!params.webhookToken) { + throw new ForbiddenException('Missing webhook token'); + } + + if ( + !this.hashesEqual( + this.hashValue(params.webhookToken), + handshakeState.tokenHash, + ) + ) { + throw new ForbiddenException('Invalid webhook token'); + } + + const payloadHash = this.hashValue(JSON.stringify(params.payload)); + const duplicateByEventId = + Boolean(params.eventId) && handshakeState.lastEventId === params.eventId; + const duplicateByPayload = + !params.eventId && handshakeState.lastPayloadHash === payloadHash; + const duplicate = duplicateByEventId || duplicateByPayload; + + const nextHandshakeState: PersistedWebhookHandshake = { + ...handshakeState, + lastPayloadHash: payloadHash, + lastWebhookAt: new Date().toISOString(), + ...(params.eventId ? { lastEventId: params.eventId } : {}), + }; + + await db.secret.update({ + where: { + id: storedHandshake.id, + }, + data: { + value: JSON.stringify(nextHandshakeState), + lastUsedAt: new Date(), + }, + }); + + return duplicate; + } +} diff --git a/apps/app/package.json b/apps/app/package.json index 851ce7ecd6..698c46bd05 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -136,6 +136,7 @@ "@types/jspdf": "^2.0.0", "@types/node": "^24.0.3", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "3.2.4", "@vitest/ui": "^3.2.4", "eslint": "^9.18.0", "eslint-config-next": "15.5.2", diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx index 2833f0fc75..828df1cb8d 100644 --- a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -7,7 +7,7 @@ import { NotificationBell } from '@/components/notifications/notification-bell'; import { OrganizationSwitcher } from '@/components/organization-switcher'; import { SidebarProvider, useSidebar } from '@/context/sidebar-context'; import { authClient } from '@/utils/auth-client'; -import { CertificateCheck, CloudAuditing, Logout, Settings } from '@carbon/icons-react'; +import { CertificateCheck, CloudAuditing, Logout, Security, Settings } from '@carbon/icons-react'; import { DropdownMenu, DropdownMenuContent, @@ -19,16 +19,17 @@ import { import type { Onboarding, Organization } from '@db'; import { AppShell, - AppShellAIChatTrigger, AppShellBody, AppShellContent, AppShellMain, AppShellNavbar, AppShellRail, + AppShellAIChatTrigger, AppShellRailItem, AppShellSidebar, AppShellSidebarHeader, AppShellUserMenu, + TooltipProvider, Avatar, AvatarFallback, AvatarImage, @@ -42,8 +43,9 @@ import { useAction } from 'next-safe-action/hooks'; import { useTheme } from 'next-themes'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { Suspense, useCallback, useRef } from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { SettingsSidebar } from '../settings/components/SettingsSidebar'; +import { SecuritySidebar } from '../security/components/SecuritySidebar'; import { TrustSidebar } from '../trust/components/TrustSidebar'; import { getAppShellSearchGroups } from './app-shell-search-groups'; import { AppSidebar } from './AppSidebar'; @@ -59,6 +61,7 @@ interface AppShellWrapperProps { isQuestionnaireEnabled: boolean; isTrustNdaEnabled: boolean; isWebAutomationsEnabled: boolean; + isSecurityEnabled: boolean; hasAuditorRole: boolean; isOnlyAuditor: boolean; user: { @@ -87,6 +90,7 @@ function AppShellWrapperContent({ isQuestionnaireEnabled, isTrustNdaEnabled, isWebAutomationsEnabled, + isSecurityEnabled, hasAuditorRole, isOnlyAuditor, user, @@ -98,6 +102,16 @@ function AppShellWrapperContent({ const previousIsCollapsedRef = useRef(isCollapsed); const isSettingsActive = pathname?.startsWith(`/${organization.id}/settings`); const isTrustActive = pathname?.startsWith(`/${organization.id}/trust`); + const isSecurityActive = pathname?.startsWith(`/${organization.id}/security`); + const [logoVariant, setLogoVariant] = useState<'dark' | 'light'>('dark'); + + useEffect(() => { + if (!resolvedTheme) { + return; + } + + setLogoVariant(resolvedTheme === 'light' ? 'dark' : 'light'); + }, [resolvedTheme]); const { execute } = useAction(updateSidebarState, { onError: () => { @@ -122,159 +136,203 @@ function AppShellWrapperContent({ isOnlyAuditor, isQuestionnaireEnabled, isTrustNdaEnabled, + isSecurityEnabled, isAdvancedModeEnabled: organization.advancedModeEnabled, }); return ( - } - sidebarOpen={!isCollapsed} - onSidebarOpenChange={handleSidebarOpenChange} - > - - - + } + sidebarOpen={!isCollapsed} + onSidebarOpenChange={handleSidebarOpenChange} + > + + + + + / + - - / - - - } - centerContent={} - endContent={ - - - - - - - {user.image && } - - {user.name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()} - - - - -
- - {user.name} - - - {user.email} - -
- - - - - - Settings - - - - -
- Theme - setTheme(value)} - showSystem - /> -
- - { - await authClient.signOut({ - fetchOptions: { - onSuccess: () => { - router.push('/auth'); - }, - }, - }); - }} + + } + centerContent={} + endContent={ + + + + + - - Log out - -
-
-
- } - /> - - - - + {user.image && } + + {user.name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()} + + + + +
+ + {user.name} + + + {user.email} + +
+ + + + + + Settings + + + + +
+ Theme + setTheme(value)} + showSystem + /> +
+ + { + await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + router.push('/auth'); + }, + }, + }); + }} + > + + Log out + +
+ + + } + /> + + + } label="Compliance" /> - - {isTrustNdaEnabled && ( - - } label="Trust" /> - - )} - {!isOnlyAuditor && ( - - } + label="Security" + /> + ) : null} + {!isOnlyAuditor && ( + } label="Settings" /> - - )} - - - - - {isSettingsActive ? ( - - ) : isTrustActive ? ( - - ) : ( - )} - +
+ + + + {isSettingsActive ? ( + + ) : isTrustActive ? ( + + ) : isSecurityActive && isSecurityEnabled ? ( + + ) : ( + + )} + - - {onboarding?.triggerJobId && } - {children} - - + + {onboarding?.triggerJobId && } + {children} + + - - - -
-
+ + + + +
+ + ); +} + +function ShellRailNavItem({ + href, + isActive, + icon, + label, +}: { + href: string; + isActive: boolean; + icon: React.ReactNode; + label: string; +}) { + const railItemId = `app-shell-rail-${label.toLowerCase()}`; + + return ( + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx index ad944cf2d0..3d83122001 100644 --- a/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/app-shell-search-groups.tsx @@ -26,6 +26,7 @@ interface AppShellSearchGroupsParams { isOnlyAuditor: boolean; isQuestionnaireEnabled: boolean; isTrustNdaEnabled: boolean; + isSecurityEnabled: boolean; isAdvancedModeEnabled: boolean; } @@ -62,6 +63,7 @@ export const getAppShellSearchGroups = ({ isOnlyAuditor, isQuestionnaireEnabled, isTrustNdaEnabled, + isSecurityEnabled, isAdvancedModeEnabled, }: AppShellSearchGroupsParams): CommandSearchGroup[] => { const baseItems = [ @@ -125,6 +127,18 @@ export const getAppShellSearchGroups = ({ }), ] : []), + ...(isSecurityEnabled + ? [ + createNavItem({ + id: 'security', + label: 'Security', + icon: , + path: `/${organizationId}/security`, + keywords: ['security', 'vulnerability', 'reports'], + router, + }), + ] + : []), createNavItem({ id: 'documents', label: 'Documents', diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index e77c3626a3..05946fc9b9 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -141,6 +141,7 @@ export default async function Layout({ let isQuestionnaireEnabled = false; let isTrustNdaEnabled = false; let isWebAutomationsEnabled = false; + let isSecurityEnabled = false; if (session?.user?.id) { const flags = await getFeatureFlags(session.user.id); isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; @@ -149,6 +150,7 @@ export default async function Layout({ isWebAutomationsEnabled = flags['is-web-automations-enabled'] === true || flags['is-web-automations-enabled'] === 'true'; + isSecurityEnabled = flags['is-security-enabled'] === true || flags['is-security-enabled'] === 'true'; } // Check auditor role @@ -176,6 +178,7 @@ export default async function Layout({ isQuestionnaireEnabled={isQuestionnaireEnabled} isTrustNdaEnabled={isTrustNdaEnabled} isWebAutomationsEnabled={isWebAutomationsEnabled} + isSecurityEnabled={isSecurityEnabled} hasAuditorRole={hasAuditorRole} isOnlyAuditor={isOnlyAuditor} user={user} diff --git a/apps/app/src/app/(app)/[orgId]/security/components/SecuritySidebar.tsx b/apps/app/src/app/(app)/[orgId]/security/components/SecuritySidebar.tsx new file mode 100644 index 0000000000..f62ac60b1c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/components/SecuritySidebar.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +interface SecuritySidebarProps { + orgId: string; +} + +type SecurityNavItem = { + id: string; + label: string; + path: string; +}; + +export function SecuritySidebar({ orgId }: SecuritySidebarProps) { + const pathname = usePathname() ?? ''; + + const items: SecurityNavItem[] = [ + { + id: 'penetration-tests', + label: 'Penetration Tests', + path: `/${orgId}/security/penetration-tests`, + }, + ]; + + const isPathActive = (path: string) => { + return pathname.startsWith(path); + }; + + return ( + + {items.map((item) => ( + + {item.label} + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/layout.tsx b/apps/app/src/app/(app)/[orgId]/security/layout.tsx new file mode 100644 index 0000000000..f907fcefa8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/layout.tsx @@ -0,0 +1,31 @@ +import { getFeatureFlags } from '@/app/posthog'; +import { auth } from '@/utils/auth'; +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +export default async function SecurityLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}) { + await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return notFound(); + } + + const flags = await getFeatureFlags(session.user.id); + const isSecurityEnabled = + flags['is-security-enabled'] === true || flags['is-security-enabled'] === 'true'; + + if (!isSecurityEnabled) { + return notFound(); + } + + return <>{children}; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/page.tsx b/apps/app/src/app/(app)/[orgId]/security/page.tsx new file mode 100644 index 0000000000..12b94ef913 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from 'next/navigation'; +import type { Metadata } from 'next'; + +export default async function SecurityPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + + redirect(`/${orgId}/security/penetration-tests`); +} + +export async function generateMetadata(): Promise { + return { + title: 'Security', + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.test.tsx new file mode 100644 index 0000000000..3a2fd333a8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { redirect } from 'next/navigation'; + +import PenetrationTestPage, { generateMetadata } from './page'; + +const authGetSessionMock = vi.fn(); +const dbFindFirstMock = vi.fn(); +const headersMock = vi.fn(); +const childMock = vi.fn(); + +vi.mock('@/utils/auth', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => authGetSessionMock(...args), + }, + }, +})); + +vi.mock('@db', () => ({ + db: { + member: { + findFirst: (...args: unknown[]) => dbFindFirstMock(...args), + }, + }, +})); + +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +vi.mock('next/navigation', () => ({ + redirect: vi.fn(), +})); + +vi.mock('./penetration-test-page-client', () => ({ + PenetrationTestPageClient: ({ orgId, reportId }: { orgId: string; reportId: string }) => { + childMock({ orgId, reportId }); + return ( +
+ {orgId}:{reportId} +
+ ); + }, +})); + +describe('Penetration Test detail page', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers()); + authGetSessionMock.mockResolvedValue({ user: { id: 'user_1' } }); + dbFindFirstMock.mockResolvedValue({ id: 'member_1' }); + vi.mocked(redirect).mockImplementation(() => { + const error = new Error('NEXT_REDIRECT'); + (error as Error & { digest: string }).digest = 'NEXT_REDIRECT'; + throw error; + }); + }); + + it('renders the detail client component for authorized members', async () => { + const page = await PenetrationTestPage({ + params: Promise.resolve({ orgId: 'org_1', reportId: 'run_1' }), + }); + + render(page); + + expect(screen.getByTestId('penetration-test-page-client')).toHaveTextContent('org_1:run_1'); + expect(childMock).toHaveBeenCalledWith({ orgId: 'org_1', reportId: 'run_1' }); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('redirects when user is unauthenticated', async () => { + authGetSessionMock.mockResolvedValue(null); + + await expect( + PenetrationTestPage({ params: Promise.resolve({ orgId: 'org_1', reportId: 'run_1' }) }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(redirect).toHaveBeenCalledWith('/auth'); + expect(childMock).not.toHaveBeenCalled(); + }); + + it('redirects when member cannot be found', async () => { + dbFindFirstMock.mockResolvedValue(null); + + await expect( + PenetrationTestPage({ params: Promise.resolve({ orgId: 'org_1', reportId: 'run_1' }) }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(redirect).toHaveBeenCalledWith('/'); + expect(childMock).not.toHaveBeenCalled(); + }); + + it('returns the correct metadata title', async () => { + const metadata = await generateMetadata({ params: Promise.resolve({ orgId: 'org_1', reportId: 'run_1' }) }); + + expect(metadata).toEqual({ title: 'Penetration Test run_1' }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx new file mode 100644 index 0000000000..daf8be58dc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/page.tsx @@ -0,0 +1,52 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import { PenetrationTestPageClient } from './penetration-test-page-client'; + +interface ReportPageProps { + params: Promise<{ + orgId: string; + reportId: string; + }>; +} + +export default async function PenetrationTestPage({ params }: ReportPageProps) { + const { orgId, reportId } = await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user.id) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + return ( + + Review details for this report generation. + + + ); +} + +export async function generateMetadata({ params }: ReportPageProps): Promise { + const { reportId } = await params; + return { + title: `Penetration Test ${reportId}`, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.test.tsx new file mode 100644 index 0000000000..8d91573aca --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.test.tsx @@ -0,0 +1,325 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; + +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { PenetrationTestPageClient } from './penetration-test-page-client'; + +const usePenetrationTestMock = vi.fn(); +const usePenetrationTestProgressMock = vi.fn(); +const pushMock = vi.fn(); + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock('../hooks/use-penetration-tests', () => ({ + usePenetrationTest: (...args: never[]) => usePenetrationTestMock(...args), + usePenetrationTestProgress: (...args: never[]) => usePenetrationTestProgressMock(...args), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: pushMock, + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + }), +})); + +const reportMock = usePenetrationTestMock as ReturnType; +const progressMock = usePenetrationTestProgressMock as ReturnType; + +describe('PenetrationTestPageClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows a loading indicator before the report is available', () => { + reportMock.mockReturnValue({ + report: undefined, + isLoading: true, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + const { container } = render(); + + expect(container.querySelector('.animate-spin')).toBeTruthy(); + }); + + it('shows an error state when report loading fails', () => { + reportMock.mockReturnValue({ + report: undefined, + isLoading: false, + error: new Error('Not found'), + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Unable to load report')).toBeInTheDocument(); + expect(screen.getByText('Not found')).toBeInTheDocument(); + }); + + it('falls back to a generic message when report error is not an Error instance', () => { + reportMock.mockReturnValue({ + report: undefined, + isLoading: false, + error: 'fatal payload fetch error' as never, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Unable to load report')).toBeInTheDocument(); + expect(screen.getByText('No report found for this organization.')).toBeInTheDocument(); + }); + + it('renders completed report details and artifact links', () => { + const report: PentestRun = { + id: 'run_1', + sandboxId: 'sandbox_1', + workflowId: 'workflow_1', + sessionId: 'session_1', + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + status: 'completed', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-26T18:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('https://example.com')).toBeInTheDocument(); + expect(screen.getByText('https://github.com/org/repo')).toBeInTheDocument(); + expect(screen.getByText('View markdown')).toBeInTheDocument(); + expect(screen.getByText('Download PDF')).toBeInTheDocument(); + expect(screen.queryByText('Current progress')).toBeNull(); + }); + + it('shows sandbox placeholder when sandboxId is missing', () => { + const report: PentestRun = { + id: 'run_5', + sandboxId: '', + workflowId: 'workflow_5', + sessionId: 'session_5', + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + status: 'completed', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-25T18:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows repository placeholder when repoUrl is missing', () => { + const report: PentestRun = { + id: 'run_6', + sandboxId: 'sandbox_6', + workflowId: 'workflow_6', + sessionId: 'session_6', + targetUrl: 'https://example.com', + repoUrl: null, + status: 'completed', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-25T18:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Repository')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('renders running progress section when a live report is available', async () => { + const report: PentestRun = { + id: 'run_2', + sandboxId: 'sandbox_2', + workflowId: 'workflow_2', + sessionId: 'session_2', + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + status: 'running', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-26T18:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: { + status: 'running', + phase: 'scan', + agent: null, + completedAgents: 1, + totalAgents: 2, + elapsedMs: 300, + }, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Running')).toBeInTheDocument(); + expect(screen.getByText('Current progress')).toBeInTheDocument(); + expect(screen.getByText('scan (1/2)')).toBeInTheDocument(); + expect(screen.queryByText('Download PDF')).toBeNull(); + }); + + it('renders progress fallback text without phase and counts when data is incomplete', async () => { + const report: PentestRun = { + id: 'run_4', + sandboxId: 'sandbox_4', + workflowId: 'workflow_4', + sessionId: 'session_4', + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + status: 'running', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-26T18:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: { + status: 'running', + phase: null, + agent: null, + completedAgents: '1' as unknown as number, + totalAgents: '2' as unknown as number, + elapsedMs: 400, + }, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Current progress')).toBeInTheDocument(); + expect(screen.getByText('In progress')).toBeInTheDocument(); + }); + + it('allows progress updates to render from the progress hook contract', () => { + const report: PentestRun = { + id: 'run_3', + sandboxId: 'sandbox_3', + workflowId: 'workflow_3', + sessionId: 'session_3', + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + status: 'failed', + createdAt: '2026-02-26T18:00:00Z', + updatedAt: '2026-02-26T18:30:00Z', + error: 'Scan failed due to provider timeout', + temporalUiUrl: 'https://temporal.ui/session', + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }; + + reportMock.mockReturnValue({ + report, + isLoading: false, + error: undefined, + mutate: vi.fn(), + }); + progressMock.mockReturnValue({ + progress: null, + isLoading: false, + }); + + render(); + + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Scan failed due to provider timeout')).toBeInTheDocument(); + expect(screen.getByText('Open temporal UI')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Open temporal UI' })).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx new file mode 100644 index 0000000000..3e82e327bd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/[reportId]/penetration-test-page-client.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import { Badge } from '@comp/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { AlertCircle, ArrowLeft, ExternalLink, FileText, Loader2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@trycompai/design-system'; +import { toast } from 'sonner'; +import { isReportInProgress, formatReportDate, statusLabel, statusVariant } from '../lib'; +import { + usePenetrationTest, + usePenetrationTestProgress, +} from '../hooks/use-penetration-tests'; + +interface PenetrationTestPageClientProps { + orgId: string; + reportId: string; +} + +const parseResponseError = async (response: Response): Promise => { + const payload = await response.text().catch(() => ''); + if (!payload) { + return `Request failed with status ${response.status}`; + } + + try { + const parsed = JSON.parse(payload) as { error?: string; message?: string }; + return parsed.error || parsed.message || payload; + } catch { + return payload; + } +}; + +const toSafeExternalHttpUrl = (value: string): string | null => { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + return parsed.toString(); + } catch { + return null; + } +}; + +export function PenetrationTestPageClient({ orgId, reportId }: PenetrationTestPageClientProps) { + const router = useRouter(); + const { report, isLoading, error } = usePenetrationTest(orgId, reportId); + const { progress } = usePenetrationTestProgress(orgId, reportId, report?.status); + + if (isLoading && !report) { + return ( +
+ +
+ ); + } + + if (error || !report) { + return ( + + + + + Unable to load report + + + {error instanceof Error ? error.message : 'No report found for this organization.'} + + + + ); + } + + const isInProgress = isReportInProgress(report.status); + const safeTemporalUiUrl = + report.temporalUiUrl ? toSafeExternalHttpUrl(report.temporalUiUrl) : null; + + const openArtifact = async (path: string, filename?: string): Promise => { + try { + const response = await api.raw(path, { + method: 'GET', + organizationId: orgId, + headers: { + Accept: filename ? 'application/pdf' : 'text/markdown', + }, + }); + + if (!response.ok) { + throw new Error(await parseResponseError(response)); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + + if (filename) { + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + window.open(objectUrl, '_blank', 'noopener,noreferrer'); + } + + window.setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 60_000); + } catch (artifactError) { + toast.error( + artifactError instanceof Error + ? artifactError.message + : 'Failed to open report artifact', + ); + } + }; + + return ( +
+
+ +
+ + + + Report summary + + Track run status, view links, and access artifacts when complete. + + + +
+
+

Status

+ {statusLabel[report.status]} +
+
+

Created

+

{formatReportDate(report.createdAt)}

+
+
+

Target URL

+

{report.targetUrl}

+
+
+

Repository

+

{report.repoUrl || '—'}

+
+
+

Last update

+

{formatReportDate(report.updatedAt)}

+
+
+

Sandbox

+

{report.sandboxId || '—'}

+
+
+ + {report.error && ( +
+

Run error

+

{report.error}

+
+ )} + + {isInProgress && progress ? ( +
+

Current progress

+

+ {progress.phase || 'In progress'} + {typeof progress.completedAgents === 'number' && + typeof progress.totalAgents === 'number' + ? ` (${progress.completedAgents}/${progress.totalAgents})` + : ''} +

+
+ ) : null} +
+
+ + + + Deliverables + Open report outputs after completion. + + + + {report.status === 'completed' ? ( + + ) : null} + {safeTemporalUiUrl ? ( + + ) : null} + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.test.tsx new file mode 100644 index 0000000000..e2ffdd7e00 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.test.tsx @@ -0,0 +1,111 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { redirect } from 'next/navigation'; + +import PenetrationTestCheckoutPage, { generateMetadata } from './page'; + +const authGetSessionMock = vi.fn(); +const dbFindFirstMock = vi.fn(); +const headersMock = vi.fn(); + +vi.mock('@/utils/auth', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => authGetSessionMock(...args), + }, + }, +})); + +vi.mock('@db', () => ({ + db: { + member: { + findFirst: (...args: unknown[]) => dbFindFirstMock(...args), + }, + }, +})); + +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +vi.mock('next/navigation', () => ({ + redirect: vi.fn(), +})); + +describe('Penetration test checkout page', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers()); + authGetSessionMock.mockResolvedValue({ user: { id: 'user_1' } }); + dbFindFirstMock.mockResolvedValue({ id: 'member_1' }); + vi.mocked(redirect).mockImplementation(() => { + const error = new Error('NEXT_REDIRECT'); + (error as Error & { digest: string }).digest = 'NEXT_REDIRECT'; + throw error; + }); + }); + + it('renders checkout call-to-action when session and membership are valid', async () => { + const page = await PenetrationTestCheckoutPage({ + params: Promise.resolve({ orgId: 'org_1' }), + searchParams: Promise.resolve({ reportId: 'run_1' }), + }); + + render(page); + + expect(screen.getByRole('heading', { name: /Mock Checkout/i })).toBeInTheDocument(); + const purchaseButton = screen.getByRole('button', { name: /Complete Purchase/i }); + const purchaseForm = purchaseButton.closest('form'); + + expect(purchaseButton).toBeInTheDocument(); + expect(purchaseForm).toHaveAttribute( + 'action', + '/org_1/security/penetration-tests?checkout=success&reportId=run_1', + ); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('redirects when user is unauthenticated', async () => { + authGetSessionMock.mockResolvedValue(null); + + await expect( + PenetrationTestCheckoutPage({ + params: Promise.resolve({ orgId: 'org_1' }), + searchParams: Promise.resolve({ reportId: 'run_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(redirect).toHaveBeenCalledWith('/auth'); + }); + + it('redirects when member cannot be found', async () => { + dbFindFirstMock.mockResolvedValue(null); + + await expect( + PenetrationTestCheckoutPage({ + params: Promise.resolve({ orgId: 'org_1' }), + searchParams: Promise.resolve({ reportId: 'run_1' }), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(redirect).toHaveBeenCalledWith('/'); + }); + + it('redirects back to report list when reportId is missing', async () => { + await expect( + PenetrationTestCheckoutPage({ + params: Promise.resolve({ orgId: 'org_1' }), + searchParams: Promise.resolve({}), + }), + ).rejects.toThrow('NEXT_REDIRECT'); + + expect(redirect).toHaveBeenCalledWith('/org_1/security/penetration-tests'); + }); + + it('returns the correct metadata title', async () => { + const metadata = await generateMetadata(); + + expect(metadata).toEqual({ title: 'Mock Penetration Test Checkout' }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.tsx new file mode 100644 index 0000000000..1f57cf946d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/checkout/page.tsx @@ -0,0 +1,80 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; +import { ArrowRight } from 'lucide-react'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +interface CheckoutPageProps { + params: Promise<{ + orgId: string; + }>; + searchParams: Promise<{ + reportId?: string; + }>; +} + +export default async function PenetrationTestCheckoutPage({ + params, + searchParams, +}: CheckoutPageProps) { + const { orgId } = await params; + const { reportId } = await searchParams; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user.id) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: orgId, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + if (!reportId) { + redirect(`/${orgId}/security/penetration-tests`); + } + + const successUrl = `/${orgId}/security/penetration-tests?checkout=success&reportId=${encodeURIComponent(reportId)}`; + + return ( + + Continue to one-time penetration test checkout for this report instance. + + + Penetration test checkout + + This is a mocked one-time checkout flow. In production this page will be replaced by Stripe. + + + +

+ Click below to complete checkout and return to your report queue. +

+
+ +
+
+
+
+ ); +} + +export async function generateMetadata(): Promise { + return { + title: 'Mock Penetration Test Checkout', + }; +} 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 new file mode 100644 index 0000000000..43af921a88 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx @@ -0,0 +1,439 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReactNode } from 'react'; +import { + useCreatePenetrationTest, + usePenetrationTest, + usePenetrationTestProgress, + usePenetrationTests, +} from './use-penetration-tests'; + +const createJsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + }, + }); + +const wrapper = ({ children }: { children: ReactNode }) => ( + new Map(), + dedupingInterval: 0, + shouldRetryOnError: false, + revalidateOnFocus: false, + refreshInterval: 0, + }} + > + {children} + +); + +describe('use-penetration-tests hooks', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads reports and splits active versus completed', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse([ + { + id: 'run_completed', + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + status: 'completed', + sandboxId: 'sb_1', + workflowId: 'wf_1', + sessionId: 's_1', + createdAt: '2025-02-01T10:00:00Z', + updatedAt: '2025-02-01T10:00:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'u_1', + organizationId: 'org_123', + }, + { + id: 'run_running', + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + status: 'running', + sandboxId: 'sb_2', + workflowId: 'wf_2', + sessionId: 's_2', + createdAt: '2025-02-03T10:00:00Z', + updatedAt: '2025-02-03T10:00:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'u_1', + organizationId: 'org_123', + }, + ]), + ); + + const { result } = renderHook(() => usePenetrationTests('org_123'), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.reports).toHaveLength(2); + expect(result.current.activeReports.map((report) => report.id)).toEqual(['run_running']); + expect(result.current.completedReports.map((report) => report.id)).toEqual(['run_completed']); + }); + + it('uses no list call when organization id is missing', () => { + const { result } = renderHook(() => usePenetrationTests(''), { wrapper }); + + expect(result.current.reports).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('surfaces provider errors for report list endpoint failures', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse( + { + error: 'provider unavailable', + }, + 503, + ), + ); + + const { result } = renderHook(() => usePenetrationTests('org_123'), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toEqual(expect.any(Error)); + expect(result.current.error?.message).toBe('provider unavailable'); + expect(result.current.reports).toEqual([]); + }); + + it('loads report detail and progress while running', async () => { + fetchMock + .mockResolvedValueOnce( + createJsonResponse({ + id: 'run_running', + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + status: 'running', + sandboxId: 'sb_2', + workflowId: 'wf_2', + sessionId: 's_2', + createdAt: '2025-02-03T10:00:00Z', + updatedAt: '2025-02-03T10:00:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'u_1', + organizationId: 'org_123', + }), + ) + .mockResolvedValueOnce( + createJsonResponse({ + status: 'running', + phase: 'scan', + agent: null, + completedAgents: 1, + totalAgents: 3, + elapsedMs: 500, + }), + ); + + const { result } = renderHook(() => usePenetrationTest('org_123', 'run_running'), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.report?.id).toBe('run_running'); + + const progress = renderHook( + () => usePenetrationTestProgress('org_123', 'run_running', result.current.report?.status), + { wrapper }, + ); + + await waitFor(() => expect(progress.result.current.isLoading).toBe(false)); + expect(progress.result.current.progress?.status).toBe('running'); + expect(progress.result.current.progress?.phase).toBe('scan'); + }); + + it('loads a report detail for empty id only when both identifiers are present', async () => { + const { result } = renderHook(() => usePenetrationTest('org_123', ''), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.report).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips progress polling when report is completed', () => { + const { result } = renderHook(() => usePenetrationTestProgress('org_123', 'run_completed', 'completed'), { + wrapper, + }); + + expect(result.current.progress).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips progress polling while report status is unknown', () => { + const { result } = renderHook( + () => usePenetrationTestProgress('org_123', 'run_unknown', undefined), + { wrapper }, + ); + + expect(result.current.progress).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('creates a report and returns checkout context', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.test/route', + id: 'run_123', + }), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport( + { + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + testMode: true, + mockCheckout: false, + }, + ), + ).resolves.toMatchObject({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.test/route', + id: 'run_123', + }); + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const requestBody = JSON.parse((init.body ?? '{}') as string); + expect(requestBody.orgId).toBe('org_123'); + expect(requestBody.mockCheckout).toBe(false); + expect(requestBody.targetUrl).toBe('https://app.example.com'); + expect(requestBody.repoUrl).toBe('https://github.com/org/repo'); + expect(requestBody.testMode).toBe(true); + }); + + it('supports creating a report without repository URL for black-box mode', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.test/route', + id: 'run_black_box', + }), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + }), + ).resolves.toMatchObject({ + id: 'run_black_box', + }); + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const requestBody = JSON.parse((init.body ?? '{}') as string); + expect(requestBody.targetUrl).toBe('https://app.example.com'); + expect(requestBody.repoUrl).toBeUndefined(); + }); + + it('creates a report using default mock checkout when not overridden', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.test/route', + id: 'run_456', + }), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).resolves.toMatchObject({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.test/route', + id: 'run_456', + }); + }); + + const init = fetchMock.mock.calls[0][1] as RequestInit; + const requestBody = JSON.parse((init.body ?? '{}') as string); + expect(requestBody.mockCheckout).toBe(true); + expect(requestBody.checkoutMode).toBeUndefined(); + }); + + it('fails create when stripe mode is requested but checkout URL is missing', async () => { + fetchMock.mockResolvedValueOnce( + createJsonResponse({ + checkoutMode: 'stripe', + id: 'run_stripe_no_url', + }), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + mockCheckout: false, + }), + ).rejects.toThrow('Missing checkout URL for stripe checkout mode.'); + }); + }); + + it('surfaces json provider error objects from create response', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + error: 'invalid repository URL', + }), + { status: 400 }, + ), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('invalid repository URL'); + }); + + expect(result.current.error).toBe('invalid repository URL'); + }); + + it('surfaces a request-level error when create returns non-json text', async () => { + fetchMock.mockResolvedValueOnce(new Response('service unavailable', { status: 503 })); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('service unavailable'); + }); + + expect(result.current.error).toBe('service unavailable'); + }); + + it('uses raw JSON payload text when error body is object-shaped without error/message fields', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + reason: 'provider rejected payload', + }), + { status: 500 }, + ), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('{"reason":"provider rejected payload"}'); + }); + + expect(result.current.error).toBe('{"reason":"provider rejected payload"}'); + }); + + it('uses the message field from non-empty JSON error responses', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + message: 'Invalid report configuration', + }), + { status: 400 }, + ), + ); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('Invalid report configuration'); + }); + + expect(result.current.error).toBe('Invalid report configuration'); + }); + + it('falls back to a generic error when the transport failure is not an Error', async () => { + fetchMock.mockRejectedValueOnce('network offline'); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('Failed to create report'); + }); + + expect(result.current.error).toBe('Failed to create report'); + }); + + it('treats empty create error payload as status based message', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 400 })); + + const { result } = renderHook(() => useCreatePenetrationTest('org_123'), { wrapper }); + + await act(async () => { + await expect( + result.current.createReport({ + targetUrl: 'https://app.example.com', + repoUrl: 'https://github.com/org/repo', + }), + ).rejects.toThrow('Request failed with status 400'); + }); + + expect(result.current.error).toBe('Request failed with status 400'); + }); + + it('returns an empty object when report detail API response body is empty', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + + const { result } = renderHook(() => usePenetrationTest('org_123', 'run_123'), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.report).toEqual({}); + }); +}); 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 new file mode 100644 index 0000000000..f0e739836b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -0,0 +1,268 @@ +'use client'; + +import { api } from '@/lib/api-client'; +import type { + CreatePenetrationTestResponse, + PentestCreateRequest, + PentestProgress, + PentestReportStatus, + PentestRun, +} from '@/lib/security/penetration-tests-client'; +import { useCallback, useMemo, useState } from 'react'; +import { useSWRConfig } from 'swr'; +import useSWR from 'swr'; +import { isReportInProgress, sortReportsByUpdatedAtDesc } from '../lib'; + +const reportListEndpoint = '/v1/security-penetration-tests'; +const reportEndpoint = (reportId: string): string => + `/v1/security-penetration-tests/${encodeURIComponent(reportId)}`; +const reportProgressEndpoint = (reportId: string): string => + `/v1/security-penetration-tests/${encodeURIComponent(reportId)}/progress`; + +type ReportsSWRKey = readonly [endpoint: string, organizationId: string]; + +const reportListKey = (organizationId: string): ReportsSWRKey => + [reportListEndpoint, organizationId] as const; +const reportKey = (organizationId: string, reportId: string): ReportsSWRKey => + [reportEndpoint(reportId), organizationId] as const; +const reportProgressKey = ( + organizationId: string, + reportId: string, +): ReportsSWRKey => [reportProgressEndpoint(reportId), organizationId] as const; + +async function fetchApiJson([endpoint, organizationId]: ReportsSWRKey): Promise { + const response = await api.get(endpoint, organizationId); + + if (response.status < 200 || response.status >= 300) { + throw new Error(response.error ?? `Request failed with status ${response.status}`); + } + + return (response.data ?? null) as T; +} + +interface CreatePayload { + targetUrl: string; + repoUrl?: string; + githubToken?: string; + configYaml?: string; + pipelineTesting?: boolean; + testMode?: boolean; + workspace?: string; + mockCheckout?: boolean; +} + +type CreateReportApiPayload = PentestCreateRequest; + +interface UsePenetrationTestsReturn { + reports: PentestRun[]; + isLoading: boolean; + error: Error | undefined; + mutate: () => Promise; + activeReports: PentestRun[]; + completedReports: PentestRun[]; +} + +interface UsePenetrationTestReturn { + report: PentestRun | undefined; + isLoading: boolean; + error: Error | undefined; + mutate: () => Promise; +} + +interface UsePenetrationTestProgressReturn { + progress: PentestProgress | null | undefined; + isLoading: boolean; +} + +interface UseCreatePenetrationTestReturn { + createReport: (payload: CreatePayload) => Promise; + isCreating: boolean; + error: string | null; + resetError: () => void; +} + +export function usePenetrationTests( + organizationId: string, +): UsePenetrationTestsReturn { + const shouldFetchReports = Boolean(organizationId); + const { data, error, mutate } = useSWR( + shouldFetchReports ? reportListKey(organizationId) : null, + fetchApiJson, + { + refreshInterval: (latestReports?: PentestRun[]) => { + if (!latestReports?.some(({ status }) => isReportInProgress(status))) { + return 0; + } + + return 4000; + }, + revalidateOnFocus: true, + }, + ); + + const reports = useMemo(() => sortReportsByUpdatedAtDesc(data ?? []), [data]); + const activeReports = useMemo( + () => reports.filter(({ status }) => isReportInProgress(status)), + [reports], + ); + const completedReports = useMemo( + () => reports.filter(({ status }) => !isReportInProgress(status)), + [reports], + ); + + return { + reports, + isLoading: shouldFetchReports && data === undefined && !error, + error: error as Error | undefined, + mutate, + activeReports, + completedReports, + }; +} + +export function usePenetrationTest( + organizationId: string, + reportId: string, +): UsePenetrationTestReturn { + const shouldFetchReport = Boolean(organizationId && reportId); + const { data, error, mutate } = useSWR( + shouldFetchReport ? reportKey(organizationId, reportId) : null, + fetchApiJson, + { + refreshInterval: (latestReport?: PentestRun) => { + if (!latestReport || !isReportInProgress(latestReport.status)) { + return 0; + } + + return 4000; + }, + revalidateOnFocus: true, + }, + ); + + return { + report: data, + isLoading: shouldFetchReport && data === undefined && !error, + error: error as Error | undefined, + mutate, + }; +} + +export function usePenetrationTestProgress( + organizationId: string, + reportId: string, + status: PentestReportStatus | undefined, +) { + const shouldFetch = Boolean( + organizationId && reportId && status && isReportInProgress(status), + ); + + const { data } = useSWR( + shouldFetch ? reportProgressKey(organizationId, reportId) : null, + fetchApiJson, + { + refreshInterval: shouldFetch ? 4000 : 0, + revalidateOnFocus: true, + }, + ); + + return { + progress: data, + isLoading: shouldFetch && data === undefined, + } satisfies UsePenetrationTestProgressReturn; +} + +export function useCreatePenetrationTest( + organizationId: string, +): UseCreatePenetrationTestReturn { + const { mutate } = useSWRConfig(); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + const createReport = useCallback( + async (payload: CreatePayload): Promise => { + setIsCreating(true); + setError(null); + try { + const requestedMockCheckout = payload.mockCheckout ?? true; + + const response = await api.post<{ + id?: string; + checkoutMode?: 'mock' | 'stripe'; + checkoutUrl?: string; + status?: string; + }>( + reportListEndpoint, + { + targetUrl: payload.targetUrl, + repoUrl: payload.repoUrl, + githubToken: payload.githubToken, + configYaml: payload.configYaml, + pipelineTesting: payload.pipelineTesting, + testMode: payload.testMode, + workspace: payload.workspace, + mockCheckout: requestedMockCheckout, + } satisfies CreateReportApiPayload, + organizationId, + ); + + if (response.status < 200 || response.status >= 300) { + throw new Error(response.error ?? `Request failed with status ${response.status}`); + } + + const reportId = response.data?.id; + if (!reportId) { + throw new Error('Could not resolve report ID from create response.'); + } + + const checkoutMode = + response.data?.checkoutMode ?? (requestedMockCheckout ? 'mock' : 'stripe'); + const fallbackCheckoutUrl = `/${organizationId}/security/penetration-tests/checkout?reportId=${encodeURIComponent(reportId)}`; + let checkoutUrl: string; + if (checkoutMode === 'stripe') { + const stripeCheckoutUrl = response.data?.checkoutUrl; + if (!stripeCheckoutUrl) { + throw new Error('Missing checkout URL for stripe checkout mode.'); + } + checkoutUrl = stripeCheckoutUrl; + } else { + checkoutUrl = response.data?.checkoutUrl ?? fallbackCheckoutUrl; + } + + const data: CreatePenetrationTestResponse = { + checkoutMode, + id: reportId, + status: response.data?.status, + checkoutUrl, + }; + + setIsCreating(false); + try { + await mutate(reportListKey(organizationId)); + } catch (revalidateError) { + console.error( + 'Created penetration test but failed to refresh report list', + revalidateError, + ); + } + return data; + } catch (reportError) { + const message = + reportError instanceof Error + ? reportError.message + : 'Failed to create report'; + setError(message); + setIsCreating(false); + throw new Error(message); + } + }, + [organizationId, mutate], + ); + + return { + createReport, + isCreating, + error, + resetError: () => setError(null), + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts new file mode 100644 index 0000000000..e42647934c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { formatReportDate, isReportInProgress, sortReportsByUpdatedAtDesc, statusLabel } from './lib'; + +describe('penetration test lib helpers', () => { + it('sortReportsByUpdatedAtDesc orders newest first', () => { + const sorted = sortReportsByUpdatedAtDesc([ + { updatedAt: '2023-01-01T00:00:00Z', id: 'old', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, + { updatedAt: '2024-01-02T00:00:00Z', id: 'new', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, + { updatedAt: 'invalid-date', id: 'bad', targetUrl: '', repoUrl: '', status: 'completed', createdAt: '', error: null, temporalUiUrl: null, webhookUrl: null, userId: '', organizationId: '', sandboxId: '', workflowId: '', sessionId: '' }, + ]); + + expect(sorted.map((report) => report.id)).toEqual(['new', 'old', 'bad']); + }); + + it('isReportInProgress returns true only for active lifecycle states', () => { + expect(isReportInProgress('provisioning')).toBe(true); + expect(isReportInProgress('cloning')).toBe(true); + expect(isReportInProgress('running')).toBe(true); + expect(isReportInProgress('completed')).toBe(false); + expect(isReportInProgress('failed')).toBe(false); + expect(isReportInProgress('cancelled')).toBe(false); + }); + + it('provides stable human labels for all known states', () => { + expect(statusLabel).toMatchObject({ + provisioning: 'Queued', + cloning: 'Preparing', + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', + }); + }); + + it('falls back to raw value when date formatting fails', () => { + expect(formatReportDate('not-a-date')).toBe('not-a-date'); + }); + + it('sorts as equal when both timestamps are invalid', () => { + const sorted = sortReportsByUpdatedAtDesc([ + { + updatedAt: 'invalid-date', + id: 'first', + targetUrl: '', + repoUrl: '', + status: 'completed', + createdAt: '', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: '', + organizationId: '', + sandboxId: '', + workflowId: '', + sessionId: '', + }, + { + updatedAt: 'also-invalid', + id: 'second', + targetUrl: '', + repoUrl: '', + status: 'completed', + createdAt: '', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: '', + organizationId: '', + sandboxId: '', + workflowId: '', + sessionId: '', + }, + ]); + + expect(sorted.map((report) => report.id)).toEqual(['first', 'second']); + }); + + it('sorts invalid timestamps as oldest', () => { + const sorted = sortReportsByUpdatedAtDesc([ + { + updatedAt: 'invalid-date', + id: 'invalid', + targetUrl: '', + repoUrl: '', + status: 'completed', + createdAt: '', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: '', + organizationId: '', + sandboxId: '', + workflowId: '', + sessionId: '', + }, + { + updatedAt: '2025-02-01T10:00:00Z', + id: 'valid', + targetUrl: '', + repoUrl: '', + status: 'completed', + createdAt: '', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: '', + organizationId: '', + sandboxId: '', + workflowId: '', + sessionId: '', + }, + ]); + + expect(sorted.map((report) => report.id)).toEqual(['valid', 'invalid']); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.ts new file mode 100644 index 0000000000..b86e8c9ea7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/lib.ts @@ -0,0 +1,50 @@ +import { type PentestReportStatus, type PentestRun } from '@/lib/security/penetration-tests-client'; + +export const statusLabel: Record = { + provisioning: 'Queued', + cloning: 'Preparing', + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', +}; + +export const statusVariant: Record< + PentestReportStatus, + 'default' | 'destructive' | 'warning' | 'success' +> = { + provisioning: 'warning', + cloning: 'warning', + running: 'warning', + completed: 'success', + failed: 'destructive', + cancelled: 'destructive', +}; + +export const isReportInProgress = (status: PentestReportStatus): boolean => { + return status === 'provisioning' || status === 'cloning' || status === 'running'; +}; + +export const sortReportsByUpdatedAtDesc = (reports: PentestRun[]): PentestRun[] => { + return [...reports].sort((left, right) => { + const rightTime = new Date(right.updatedAt).getTime(); + const leftTime = new Date(left.updatedAt).getTime(); + if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) { + return 0; + } + if (Number.isNaN(leftTime)) return 1; + if (Number.isNaN(rightTime)) return -1; + return rightTime - leftTime; + }); +}; + +export const formatReportDate = (value: string): string => { + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); + } catch { + return value; + } +}; 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 new file mode 100644 index 0000000000..97ca9aba37 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { redirect } from 'next/navigation'; + +import PenetrationTestsPage, { generateMetadata } from './page'; + +const authGetSessionMock = vi.fn(); +const dbFindFirstMock = vi.fn(); +const headersMock = vi.fn(); +const childMock = vi.fn(); + +vi.mock('@/utils/auth', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => authGetSessionMock(...args), + }, + }, +})); + +vi.mock('@db', () => ({ + db: { + member: { + findFirst: (...args: unknown[]) => dbFindFirstMock(...args), + }, + }, +})); + +vi.mock('next/headers', () => ({ + headers: () => headersMock(), +})); + +vi.mock('next/navigation', () => ({ + redirect: vi.fn(), +})); + +vi.mock('./penetration-tests-page-client', () => ({ + PenetrationTestsPageClient: ({ orgId }: { orgId: string }) => { + childMock(orgId); + return
{orgId}
; + }, +})); + +describe('Penetration Tests page', () => { + beforeEach(() => { + vi.clearAllMocks(); + headersMock.mockReturnValue(new Headers()); + authGetSessionMock.mockResolvedValue({ user: { id: 'user_1' } }); + dbFindFirstMock.mockResolvedValue({ id: 'member_1' }); + vi.mocked(redirect).mockImplementation(() => { + const error = new Error('NEXT_REDIRECT'); + (error as Error & { digest: string }).digest = 'NEXT_REDIRECT'; + throw error; + }); + }); + + it('renders the client page for authorized members', async () => { + const page = await PenetrationTestsPage({ params: Promise.resolve({ orgId: 'org_1' }) }); + + render(page); + + expect(screen.getByTestId('penetration-tests-page-client')).toHaveTextContent('org_1'); + expect(childMock).toHaveBeenCalledWith('org_1'); + expect(redirect).not.toHaveBeenCalled(); + }); + + it('redirects when user is unauthenticated', async () => { + authGetSessionMock.mockResolvedValue(null); + + await expect(PenetrationTestsPage({ params: Promise.resolve({ orgId: 'org_1' }) })).rejects.toThrow( + 'NEXT_REDIRECT', + ); + + expect(redirect).toHaveBeenCalledWith('/auth'); + expect(dbFindFirstMock).not.toHaveBeenCalled(); + expect(childMock).not.toHaveBeenCalled(); + }); + + it('redirects when member cannot be found', async () => { + dbFindFirstMock.mockResolvedValue(null); + + await expect(PenetrationTestsPage({ params: Promise.resolve({ orgId: 'org_1' }) })).rejects.toThrow( + 'NEXT_REDIRECT', + ); + + expect(authGetSessionMock).toHaveBeenCalledWith({ headers: expect.any(Headers) }); + expect(redirect).toHaveBeenCalledWith('/'); + expect(childMock).not.toHaveBeenCalled(); + }); + + it('returns the correct metadata title', async () => { + const metadata = await generateMetadata(); + + expect(metadata).toEqual({ + title: 'Penetration Tests', + }); + }); +}); 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 new file mode 100644 index 0000000000..c51f4ad16a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/page.tsx @@ -0,0 +1,42 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import { headers } from 'next/headers'; +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; + +import { PenetrationTestsPageClient } from './penetration-tests-page-client'; + +export default async function PenetrationTestsPage({ + params, +}: { + params: Promise<{ orgId: string }>; +}) { + const { orgId } = await params; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user.id) { + redirect('/auth'); + } + + const member = await db.member.findFirst({ + where: { + organizationId: orgId, + userId: session.user.id, + deactivated: false, + }, + }); + + if (!member) { + redirect('/'); + } + + return ; +} + +export async function generateMetadata(): Promise { + return { + title: 'Penetration Tests', + }; +} 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 new file mode 100644 index 0000000000..34f59ca862 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx @@ -0,0 +1,779 @@ +import { cloneElement, isValidElement, type ComponentProps, type ReactNode } from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PentestRun } from '@/lib/security/penetration-tests-client'; +import { PenetrationTestsPageClient } from './penetration-tests-page-client'; + +const useSearchParamsMock = vi.fn(); +const replaceMock = vi.fn(); + +const reportHookMock = vi.fn(); +const createHookMock = vi.fn(); +const createReportMock = vi.fn(); +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); +let originalLocation: Location | null = null; +let locationAssignMock: ReturnType; + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock('next/navigation', () => ({ + useSearchParams: () => useSearchParamsMock(), + useRouter: () => ({ + replace: replaceMock, + push: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock('sonner', () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +vi.mock('./hooks/use-penetration-tests', () => ({ + usePenetrationTests: (...args: never[]) => reportHookMock(...args), + useCreatePenetrationTest: (...args: never[]) => createHookMock(...args), + usePenetrationTest: vi.fn(), + usePenetrationTestProgress: vi.fn(), +})); + +vi.mock('@comp/ui/input', () => ({ + Input: (props: React.ComponentProps<'input'>) => , +})); + +vi.mock('@comp/ui/label', () => ({ + Label: ({ children, ...props }: React.ComponentProps<'label'>) => , +})); + +vi.mock('@comp/ui/table', () => ({ + Table: ({ children }: { children: ReactNode }) => {children}
, + TableBody: ({ children }: { children: ReactNode }) => {children}, + TableCell: ({ children }: { children: ReactNode }) => {children}, + TableHead: ({ children }: { children: ReactNode }) => {children}, + TableHeader: ({ children }: { children: ReactNode }) => {children}, + TableRow: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('@comp/ui/dialog', () => ({ + Dialog: ({ children, open }: { children: ReactNode; open: boolean }) => ( +
{children}
+ ), + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock('@comp/ui/card', () => ({ + Card: ({ children }: { children: ReactNode }) =>
{children}
, + CardContent: ({ children }: { children: ReactNode }) =>
{children}
, + CardDescription: ({ children }: { children: ReactNode }) =>

{children}

, + CardHeader: ({ children }: { children: ReactNode }) =>
{children}
, + CardTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock('@comp/ui/badge', () => ({ + Badge: ({ children }: { children: ReactNode }) => {children}, +})); + +vi.mock('@trycompai/design-system', () => ({ + Button: ({ asChild, children, ...props }: ComponentProps<'button'> & { asChild?: boolean }) => { + if (asChild && isValidElement(children)) { + return cloneElement(children, { ...props }); + } + return ( + + ); + }, + PageHeader: ({ title, actions, children }: { title: string; actions?: ReactNode; children?: ReactNode }) => ( +
+

{title}

+ {actions} + {children} +
+ ), + PageLayout: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +const reportRows: PentestRun[] = [ + { + id: 'run_running', + sandboxId: 'sb1', + workflowId: 'wf1', + sessionId: 's1', + targetUrl: 'https://running.example.com', + repoUrl: 'https://github.com/org/running', + status: 'running', + createdAt: '2026-02-26T12:00:00Z', + updatedAt: '2026-02-26T13:00:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }, + { + id: 'run_completed', + sandboxId: 'sb2', + workflowId: 'wf2', + sessionId: 's2', + targetUrl: 'https://completed.example.com', + repoUrl: 'https://github.com/org/completed', + status: 'completed', + createdAt: '2026-02-25T12:00:00Z', + updatedAt: '2026-02-25T13:00:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + }, +]; + +describe('PenetrationTestsPageClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSearchParamsMock.mockReturnValue(new URLSearchParams()); + + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [], + }); + + createReportMock.mockReset(); + createReportMock.mockResolvedValue({ + checkoutMode: 'mock', + checkoutUrl: 'https://checkout.local/example', + id: 'run_new', + }); + + createHookMock.mockReturnValue({ + createReport: createReportMock, + isCreating: false, + error: null, + resetError: vi.fn(), + }); + + if (typeof window !== 'undefined') { + originalLocation = window.location; + locationAssignMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + assign: locationAssignMock, + }, + configurable: true, + }); + } + }); + + afterEach(() => { + if (typeof window !== 'undefined' && originalLocation) { + Object.defineProperty(window, 'location', { + value: originalLocation, + configurable: true, + }); + } + }); + + it('renders an empty state and call-to-action when no reports exist', () => { + render(); + + expect(screen.getAllByText('No reports yet')).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Create your first report' })).toBeInTheDocument(); + }); + + it('renders loading state for submit button while checkout creation is in progress', () => { + createHookMock.mockReturnValue({ + createReport: createReportMock, + isCreating: true, + error: null, + resetError: vi.fn(), + }); + + const { getByText } = render(); + + fireEvent.click(getByText('Create your first report')); + + expect(screen.getByText('Redirecting...')).toBeInTheDocument(); + expect(screen.getByText('Redirecting...').closest('button')).toBeTruthy(); + }); + + it('displays completed report summary when there are no active reports', () => { + reportHookMock.mockReturnValue({ + reports: [reportRows[1]], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [reportRows[1]], + }); + + render(); + + expect(screen.getByText('1 completed report')).toBeInTheDocument(); + }); + + it('uses pluralized summary copy for multiple active and completed report counts', () => { + reportHookMock.mockReturnValue({ + reports: reportRows, + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: reportRows, + completedReports: [reportRows[1], { ...reportRows[1], id: 'run_completed_2' }], + }); + + render(); + + expect(screen.getByText('2 reports in progress')).toBeInTheDocument(); + }); + + it('uses pluralized summary copy for multiple completed reports when none are active', () => { + reportHookMock.mockReturnValue({ + reports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [{ ...reportRows[1], id: 'run_completed_2' }, reportRows[1]], + }); + + render(); + + expect(screen.getByText('2 completed reports')).toBeInTheDocument(); + }); + + it('uses fallback progress phase text when phase is missing', () => { + reportHookMock.mockReturnValue({ + reports: [ + { + id: 'run_without_phase', + sandboxId: 'sb5', + workflowId: 'wf5', + sessionId: 's5', + targetUrl: 'https://running.no-phase.example.com', + repoUrl: 'https://github.com/org/no-phase', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: null, + completedAgents: 0, + totalAgents: 2, + agent: null, + elapsedMs: 500, + }, + }, + ], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [ + { + id: 'run_without_phase', + sandboxId: 'sb5', + workflowId: 'wf5', + sessionId: 's5', + targetUrl: 'https://running.no-phase.example.com', + repoUrl: 'https://github.com/org/no-phase', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: null, + completedAgents: 0, + totalAgents: 2, + agent: null, + elapsedMs: 500, + }, + }, + ], + completedReports: [], + }); + + render(); + + expect(screen.getByText('In progress (0/2)')).toBeInTheDocument(); + }); + + it('renders running and completed reports in the table', () => { + reportHookMock.mockReturnValue({ + reports: reportRows, + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [reportRows[0]], + completedReports: [reportRows[1]], + }); + + render(); + + expect(screen.getByText('https://running.example.com')).toBeInTheDocument(); + expect(screen.getByText('https://completed.example.com')).toBeInTheDocument(); + expect(screen.getByText('1 report in progress')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'View output' })).toBeInTheDocument(); + }); + + it('renders repository fallback when repoUrl is not available', () => { + reportHookMock.mockReturnValue({ + reports: [ + { + id: 'run_no_repo', + sandboxId: 'sb_no_repo', + workflowId: 'wf_no_repo', + sessionId: 's_no_repo', + targetUrl: 'https://no-repo.example.com', + repoUrl: null, + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'scan', + completedAgents: 1, + totalAgents: 2, + agent: null, + elapsedMs: 1000, + }, + }, + ], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [ + { + id: 'run_no_repo', + sandboxId: 'sb_no_repo', + workflowId: 'wf_no_repo', + sessionId: 's_no_repo', + targetUrl: 'https://no-repo.example.com', + repoUrl: null, + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'scan', + completedAgents: 1, + totalAgents: 2, + agent: null, + elapsedMs: 1000, + }, + }, + ], + completedReports: [], + }); + + render(); + + expect(screen.getByText('https://no-repo.example.com')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows checkout success notifications from query params and clears the query', () => { + const refreshReports = vi.fn(); + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: refreshReports, + activeReports: [], + completedReports: [], + }); + useSearchParamsMock.mockReturnValue(new URLSearchParams('checkout=success&reportId=run_99')); + + render(); + + return waitFor(() => { + expect(refreshReports).toHaveBeenCalled(); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'Checkout completed. Your report run_99 is now in the queue.', + ); + expect(replaceMock).toHaveBeenCalledWith('/org_123/security/penetration-tests'); + }); + }); + + it('triggers report refresh on checkout notifications before rendering other content', async () => { + const refreshReports = vi.fn(); + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: refreshReports, + activeReports: [], + completedReports: [], + }); + useSearchParamsMock.mockReturnValue(new URLSearchParams('checkout=success&reportId=run_22')); + + render(); + + await waitFor(() => { + expect(refreshReports).toHaveBeenCalledTimes(1); + }); + }); + + it('shows a loading state while list data is loading', () => { + reportHookMock.mockReturnValue({ + reports: [], + isLoading: true, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [], + }); + + const { container } = render(); + + expect(container.querySelector('.animate-spin')).toBeTruthy(); + }); + + it('shows generic checkout success message when reportId is missing', () => { + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [], + }); + useSearchParamsMock.mockReturnValue(new URLSearchParams('checkout=success')); + + render(); + + expect(toastSuccessMock).toHaveBeenCalledWith('Checkout completed. Your report has been queued.'); + expect(replaceMock).toHaveBeenCalledWith('/org_123/security/penetration-tests'); + }); + + it('shows checkout failure notifications from query params', () => { + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [], + completedReports: [], + }); + useSearchParamsMock.mockReturnValue(new URLSearchParams('checkout=error')); + + render(); + + expect(toastErrorMock).toHaveBeenCalledWith('Checkout did not complete. Try again.'); + expect(replaceMock).toHaveBeenCalledWith('/org_123/security/penetration-tests'); + }); + + it('preserves unrelated query params while clearing checkout params', () => { + const refreshReports = vi.fn(); + reportHookMock.mockReturnValue({ + reports: [], + isLoading: false, + error: undefined, + mutate: refreshReports, + activeReports: [], + completedReports: [], + }); + useSearchParamsMock.mockReturnValue(new URLSearchParams('checkout=success&reportId=run_77&foo=bar')); + + render(); + + return waitFor(() => { + expect(refreshReports).toHaveBeenCalled(); + expect(toastSuccessMock).toHaveBeenCalledWith( + 'Checkout completed. Your report run_77 is now in the queue.', + ); + expect(replaceMock).toHaveBeenCalledWith('/org_123/security/penetration-tests?foo=bar'); + }); + }); + + it('renders progress for running report rows including phase and agent counts', () => { + reportHookMock.mockReturnValue({ + reports: [ + { + id: 'run_in_progress', + sandboxId: 'sb3', + workflowId: 'wf3', + sessionId: 's3', + targetUrl: 'https://running-progress.example.com', + repoUrl: 'https://github.com/org/running-progress', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'scan', + completedAgents: 1, + totalAgents: 2, + agent: null, + elapsedMs: 1500, + }, + }, + ], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [ + { + id: 'run_in_progress', + sandboxId: 'sb3', + workflowId: 'wf3', + sessionId: 's3', + targetUrl: 'https://running-progress.example.com', + repoUrl: 'https://github.com/org/running-progress', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'scan', + completedAgents: 1, + totalAgents: 2, + agent: null, + elapsedMs: 1500, + }, + }, + ], + completedReports: [], + }); + + render(); + + expect(screen.getByText('scan (1/2)')).toBeInTheDocument(); + }); + + it('renders progress row without completed/total counts when values are unavailable', () => { + reportHookMock.mockReturnValue({ + reports: [ + { + id: 'run_without_counts', + sandboxId: 'sb4', + workflowId: 'wf4', + sessionId: 's4', + targetUrl: 'https://running-progress.example.com', + repoUrl: 'https://github.com/org/running-progress', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'initializing', + completedAgents: 'n/a' as unknown as number, + totalAgents: 'n/a' as unknown as number, + }, + }, + ], + isLoading: false, + error: undefined, + mutate: vi.fn(), + activeReports: [ + { + id: 'run_without_counts', + sandboxId: 'sb4', + workflowId: 'wf4', + sessionId: 's4', + targetUrl: 'https://running-progress.example.com', + repoUrl: 'https://github.com/org/running-progress', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + userId: 'user_1', + organizationId: 'org_123', + progress: { + status: 'running', + phase: 'initializing', + completedAgents: 'n/a' as unknown as number, + totalAgents: 'n/a' as unknown as number, + }, + }, + ], + completedReports: [], + }); + + render(); + + expect(screen.getByText('initializing')).toBeInTheDocument(); + expect(screen.queryByText('(n/a/n/a)')).toBeNull(); + }); + + it('creates a report and redirects to checkout when submitted', async () => { + const { getByText, getByLabelText } = render(); + + await act(async () => { + fireEvent.click(getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { + target: { + value: 'https://example.com', + }, + }); + fireEvent.change(getByLabelText('Repository URL'), { + target: { + value: 'https://github.com/org/repo', + }, + }); + fireEvent.click(getByText('Continue to checkout')); + }); + + await waitFor(() => { + expect(createReportMock).toHaveBeenCalledWith({ + targetUrl: 'https://example.com', + repoUrl: 'https://github.com/org/repo', + }); + expect(toastSuccessMock).toHaveBeenCalledWith('Redirecting to checkout...'); + expect(locationAssignMock).toHaveBeenCalledWith('https://checkout.local/example'); + }); + }); + + it('requires target URL before submitting report request', async () => { + const { getByText } = render(); + const submitForm = screen.getByText('Continue to checkout').closest('form'); + + await act(async () => { + fireEvent.submit(submitForm as HTMLFormElement); + }); + + await waitFor(() => { + expect(createReportMock).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledWith('Target URL is required'); + }); + }); + + it('creates a report without repository URL when only target is provided', async () => { + const { getByText, getByLabelText } = render(); + + await act(async () => { + fireEvent.click(getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { + target: { + value: 'jungle.ai', + }, + }); + fireEvent.click(getByText('Continue to checkout')); + }); + + await waitFor(() => { + expect(createReportMock).toHaveBeenCalledWith({ + targetUrl: 'https://jungle.ai', + repoUrl: undefined, + }); + }); + }); + + it('surfaces errors when checkout creation fails', async () => { + createReportMock.mockRejectedValue(new Error('Could not start checkout')); + + const { getByText, getByLabelText } = render(); + + await act(async () => { + fireEvent.click(getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { + target: { + value: 'https://example.com', + }, + }); + fireEvent.change(getByLabelText('Repository URL'), { + target: { + value: 'https://github.com/org/repo', + }, + }); + fireEvent.click(getByText('Continue to checkout')); + }); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('Could not start checkout'); + }); + }); + + it('surfaces a generic error message when checkout creation fails with non-error value', async () => { + createReportMock.mockRejectedValue('service-down'); + + const { getByText, getByLabelText } = render(); + + await act(async () => { + fireEvent.click(getByText('Create Report')); + }); + + await act(async () => { + fireEvent.change(getByLabelText('Target URL'), { + target: { + value: 'https://example.com', + }, + }); + fireEvent.change(getByLabelText('Repository URL'), { + target: { + value: 'https://github.com/org/repo', + }, + }); + fireEvent.click(getByText('Continue to checkout')); + }); + + await waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith('Could not queue a new report'); + }); + }); +}); 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 new file mode 100644 index 0000000000..27678efe52 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { Badge } from '@comp/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@comp/ui/table'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { FormEvent, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { formatReportDate, isReportInProgress, statusLabel, statusVariant } from './lib'; +import { + useCreatePenetrationTest, + usePenetrationTests, +} from './hooks/use-penetration-tests'; +import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; + +interface PenetrationTestsPageClientProps { + orgId: string; +} + +const hasProtocol = (value: string): boolean => /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value); + +const normalizeTargetUrl = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const normalized = hasProtocol(trimmed) ? trimmed : `https://${trimmed}`; + + try { + new URL(normalized); + return normalized; + } catch { + return null; + } +}; + +export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClientProps) { + const searchParams = useSearchParams(); + const router = useRouter(); + + const [showCheckoutDialog, setShowCheckoutDialog] = useState(false); + const [targetUrl, setTargetUrl] = useState(''); + const [repoUrl, setRepoUrl] = useState(''); + + const { reports, isLoading, activeReports, completedReports, mutate: refreshReports } = + usePenetrationTests(orgId); + + const { + createReport, + isCreating, + } = useCreatePenetrationTest(orgId); + + useEffect(() => { + const checkoutStatus = searchParams.get('checkout'); + const checkoutReportId = searchParams.get('reportId'); + + if (!checkoutStatus) { + return; + } + + if (checkoutStatus === 'success' && checkoutReportId) { + toast.success(`Checkout completed. Your report ${checkoutReportId} is now in the queue.`); + } else if (checkoutStatus === 'success') { + toast.success('Checkout completed. Your report has been queued.'); + } else if (checkoutStatus === 'error') { + toast.error('Checkout did not complete. Try again.'); + } + refreshReports(); + + const cleanedSearchParams = new URLSearchParams(searchParams.toString()); + cleanedSearchParams.delete('checkout'); + cleanedSearchParams.delete('reportId'); + + const cleanQuery = cleanedSearchParams.toString(); + router.replace(`/${orgId}/security/penetration-tests${cleanQuery ? `?${cleanQuery}` : ''}`); + }, [refreshReports, orgId, searchParams, router]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const trimmedTargetUrl = targetUrl.trim(); + if (!trimmedTargetUrl) { + toast.error('Target URL is required'); + return; + } + const normalizedTargetUrl = normalizeTargetUrl(trimmedTargetUrl); + if (!normalizedTargetUrl) { + toast.error('Enter a valid target URL'); + return; + } + + try { + const response = await createReport({ + targetUrl: normalizedTargetUrl, + repoUrl: repoUrl.trim() || undefined, + }); + + setTargetUrl(''); + setRepoUrl(''); + setShowCheckoutDialog(false); + toast.success('Redirecting to checkout...'); + window.location.assign(response.checkoutUrl); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Could not queue a new report'); + } + }; + + return ( + + setShowCheckoutDialog(true)}>Create Report + } + > + Run one-time penetration tests with Maced and review generated reports. + + + + + + Queue a penetration test + + This uses a mocked one-time checkout flow for now. You’ll be redirected to checkout and + back. + + +
+
+ + setTargetUrl(event.target.value)} + required + /> +
+
+ + setRepoUrl(event.target.value)} + /> +

+ Optional. Leave blank to run a black-box scan. +

+
+ + + + +
+
+
+ + + + Your reports ({reports.length}) + + {activeReports.length > 0 + ? `${activeReports.length} report${activeReports.length === 1 ? '' : 's'} in progress` + : completedReports.length > 0 + ? `${completedReports.length} completed report${completedReports.length === 1 ? '' : 's'}` + : 'No reports yet'} + + + + {isLoading ? ( +
+ +
+ ) : reports.length === 0 ? ( +
+ +

No reports yet

+

+ Create your first one-time penetration test to get started. +

+
+ +
+
+ ) : ( + + + + Target + Repository + Status + Progress + Last update + Actions + + + + {reports.map((report) => ( + + {report.targetUrl} + {report.repoUrl || '—'} + + {statusLabel[report.status]} + + + {report.progress ? ( + + {report.progress.phase ?? 'In progress'} + {typeof report.progress.completedAgents === 'number' && + typeof report.progress.totalAgents === 'number' + ? ` (${report.progress.completedAgents}/${report.progress.totalAgents})` + : ''} + + ) : isReportInProgress(report.status) ? ( + In queue + ) : ( + + )} + + {formatReportDate(report.updatedAt)} + + + {report.status === 'completed' ? ( + Ready + ) : ( + Pending + )} + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/apps/app/src/components/notifications/notification-bell.tsx b/apps/app/src/components/notifications/notification-bell.tsx index 97c1644e2e..c6f44e4d83 100644 --- a/apps/app/src/components/notifications/notification-bell.tsx +++ b/apps/app/src/components/notifications/notification-bell.tsx @@ -66,7 +66,7 @@ export function NotificationBell() { }; return ( -
+
(