From bf79699604cc9a6d417a4239290ba91ae41f044b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:48:08 -0500 Subject: [PATCH 01/12] CS-167 Add a way to delete the document submission by admin or org owner (#2204) * feat(app): show confirmation for submission deletion * feat(api): create a delete endpoint to remove a submission * feat(app): integrate delete-submission endpoint on documents page * fix(app): add delete submission with findings cache invalidation * fix(api): make delete-submission api endpoint accessible by admin/owner * fix(app): make delete-submission button visible only for admin/owner * fix(app): keep submission-delete dialog open until delete completes and show laoding state --------- Co-authored-by: chasprowebdev --- .../evidence-forms.controller.ts | 21 +++ .../evidence-forms/evidence-forms.service.ts | 47 +++++++ .../components/CompanyFormPageClient.tsx | 125 +++++++++++++++++- 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.ts b/apps/api/src/evidence-forms/evidence-forms.controller.ts index 63bc12b5d..a9421c09b 100644 --- a/apps/api/src/evidence-forms/evidence-forms.controller.ts +++ b/apps/api/src/evidence-forms/evidence-forms.controller.ts @@ -4,6 +4,7 @@ import type { AuthContext as AuthContextType } from '@/auth/types'; import { Body, Controller, + Delete, Get, Header, Param, @@ -127,6 +128,26 @@ export class EvidenceFormsController { }); } + @Delete(':formType/submissions/:submissionId') + @ApiOperation({ + summary: 'Delete a submission', + description: + 'Remove an evidence form submission for the active organization. Requires owner, admin, or auditor role.', + }) + async deleteSubmission( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('formType') formType: string, + @Param('submissionId') submissionId: string, + ) { + return this.evidenceFormsService.deleteSubmission({ + organizationId, + authContext, + formType, + submissionId, + }); + } + @Post(':formType/submissions') @ApiOperation({ summary: 'Submit evidence form entry', diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts index 59753d79d..4b4c882de 100644 --- a/apps/api/src/evidence-forms/evidence-forms.service.ts +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -46,6 +46,7 @@ const reviewSchema = z.object({ }); const EVIDENCE_FORM_REVIEWER_ROLES = ['owner', 'admin', 'auditor'] as const; +const EVIDENCE_FORM_DELETE_ROLES = ['owner', 'admin'] as const; const MAX_UPLOAD_FILE_SIZE_BYTES = 100 * 1024 * 1024; const MAX_UPLOAD_BASE64_LENGTH = Math.ceil(MAX_UPLOAD_FILE_SIZE_BYTES / 3) * 4; @@ -159,6 +160,20 @@ export class EvidenceFormsService { return userId; } + private requireEvidenceDeleteAccess(authContext: AuthContext): string { + const userId = this.requireJwtUser(authContext); + const roles = authContext.userRoles ?? []; + const canDelete = EVIDENCE_FORM_DELETE_ROLES.some((role) => roles.includes(role)); + + if (!canDelete) { + throw new UnauthorizedException( + `Delete denied. Required one of roles: ${EVIDENCE_FORM_DELETE_ROLES.join(', ')}`, + ); + } + + return userId; + } + private decodeBase64File(fileData: string): Buffer { const normalized = fileData.trim(); if (normalized.length === 0 || normalized.length % 4 !== 0) { @@ -315,6 +330,38 @@ export class EvidenceFormsService { }; } + async deleteSubmission(params: { + organizationId: string; + authContext: AuthContext; + formType: string; + submissionId: string; + }) { + this.requireEvidenceDeleteAccess(params.authContext); + + const parsedType = evidenceFormTypeSchema.safeParse(params.formType); + if (!parsedType.success) { + throw new BadRequestException('Unsupported form type'); + } + + const submission = await db.evidenceSubmission.findFirst({ + where: { + id: params.submissionId, + organizationId: params.organizationId, + formType: toDbEvidenceFormType(parsedType.data), + }, + }); + + if (!submission) { + throw new NotFoundException('Submission not found'); + } + + await db.evidenceSubmission.delete({ + where: { id: params.submissionId }, + }); + + return { success: true, id: params.submissionId }; + } + async submitForm(params: { organizationId: string; formType: string; diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx index da14356e3..380e05a88 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/CompanyFormPageClient.tsx @@ -10,8 +10,19 @@ import { api } from '@/lib/api-client'; import { useActiveMember } from '@/utils/auth-client'; import { jwtManager } from '@/utils/jwt-manager'; import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Badge, Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, Empty, EmptyDescription, EmptyHeader, @@ -29,7 +40,15 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Add, Catalog, Download, Search, Upload } from '@trycompai/design-system/icons'; +import { + Add, + Catalog, + Download, + OverflowMenuVertical, + Search, + TrashCan, + Upload, +} from '@trycompai/design-system/icons'; import { Dialog, DialogContent, @@ -79,6 +98,7 @@ const submittedByColumnWidth = 128; const statusColumnWidth = 176; const meetingTypeColumnWidth = 140; const summaryColumnWidth = 280; +const actionsColumnWidth = 80; // ─── Helpers ───────────────────────────────────────────────── @@ -135,6 +155,9 @@ export function CompanyFormPageClient({ const [isUploading, setIsUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [submissionToDelete, setSubmissionToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); const { data: activeMember } = useActiveMember(); const memberRoles = activeMember?.role?.split(',').map((role: string) => role.trim()) || []; @@ -315,6 +338,44 @@ export function CompanyFormPageClient({ } }, [selectedFile, isMeeting, formType, organizationId, query, globalMutate]); + const handleConfirmDelete = useCallback(async () => { + if (!submissionToDelete) return; + + const submissionFormType = (submissionToDelete.formType ?? formType) as EvidenceFormType; + setIsDeleting(true); + try { + const response = await api.delete<{ success: boolean; id: string }>( + `/v1/evidence-forms/${submissionFormType}/submissions/${submissionToDelete.id}`, + organizationId, + ); + + if (response.error || !response.data?.success) { + throw new Error(response.error ?? 'Failed to delete submission'); + } + + toast.success('Submission deleted'); + setDeleteDialogOpen(false); + setSubmissionToDelete(null); + + if (isMeeting) { + for (const subType of MEETING_SUB_TYPES) { + globalMutate([`/v1/evidence-forms/${subType}${query}`, organizationId]); + } + for (const subType of MEETING_SUB_TYPES) { + globalMutate([`/v1/findings?evidenceFormType=${subType}`, organizationId]); + } + globalMutate([`/v1/findings?evidenceFormType=meeting`, organizationId]); + } else { + globalMutate([`/v1/evidence-forms/${formType}${query}`, organizationId]); + globalMutate([`/v1/findings?evidenceFormType=${formType}`, organizationId]); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete submission'); + } finally { + setIsDeleting(false); + } + }, [submissionToDelete, formType, isMeeting, organizationId, query, globalMutate]); + return (
{formType === 'access-request' && } {showSummaryColumn && } + {isAdminOrOwner && } @@ -417,6 +479,11 @@ export function CompanyFormPageClient({ )} {showSummaryColumn && Summary} + {isAdminOrOwner && ( + +
Actions
+
+ )}
@@ -475,6 +542,33 @@ export function CompanyFormPageClient({ )} + {isAdminOrOwner && ( + e.stopPropagation()}> +
+ + e.stopPropagation()} + > + + + + { + e.stopPropagation(); + setSubmissionToDelete(submission); + setDeleteDialogOpen(true); + }} + > + + Delete + + + +
+
+ )} ); })} @@ -541,6 +635,35 @@ export function CompanyFormPageClient({ + + { + setDeleteDialogOpen(open); + if (!open) setSubmissionToDelete(null); + }} + > + + + Delete submission + + Are you sure you want to delete this submission? This action cannot be undone. + + + + Cancel + + + +
); } From 8a5b1158af078f2711cad374a761f61f12c1fc7e Mon Sep 17 00:00:00 2001 From: claudio Date: Tue, 3 Mar 2026 10:33:18 -0500 Subject: [PATCH 02/12] fix(security): incremental penetration-tests lifecycle and webhook contract fixes (#2208) * feat(security): align penetration test lifecycle with Maced contract * fix(security): remove empty-string defaults from Maced run parser * test(api): add Maced contract canary e2e and CI workflow * fix(security): make Maced run parser transition-safe for nullish runtime IDs * fix(security): align Maced client and types to canonical OpenAPI contract - Drop fields absent from spec: sandboxId, workflowId, sessionId, failedReason (from Maced), userId, organizationId, phase, agent, completedAgentNames from all schemas, interfaces and test fixtures - Add notificationEmail to PentestRun schema, CreatePentestRequest payload, app-side PentestRun interface and PentestCreateRequest - Move webhookToken to a dedicated CreatePentestRun schema (POST-only) - Progress cell now renders "In progress (x/y)" without phase prefix - Update canary assertions and all unit test mocks to match new shapes Co-Authored-By: Claude Sonnet 4.6 * fix(ci): skip prisma generate in maced contract canary workflow The shared bun-install action runs `bun prisma generate` which fails at the repo root since the Prisma schema lives in a sub-package. The canary only needs the HTTP client and has no Prisma dependency, so inline the install without the generate step. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/maced-contract-canary.yml | 35 ++ apps/api/package.json | 1 + .../src/security-penetration-tests/README.md | 11 + .../maced-client.ts | 53 +-- .../security-penetration-tests.service.ts | 53 +-- apps/api/test/maced-contract.e2e-spec.ts | 87 +++++ .../penetration-test-page-client.test.tsx | 67 +--- .../penetration-test-page-client.tsx | 15 +- .../hooks/use-penetration-tests.test.tsx | 19 +- .../hooks/use-penetration-tests.ts | 65 +++- .../penetration-tests-page-client.test.tsx | 304 +++++------------- .../penetration-tests-page-client.tsx | 10 +- .../lib/security/penetration-tests-client.ts | 12 +- 13 files changed, 360 insertions(+), 372 deletions(-) create mode 100644 .github/workflows/maced-contract-canary.yml create mode 100644 apps/api/test/maced-contract.e2e-spec.ts diff --git a/.github/workflows/maced-contract-canary.yml b/.github/workflows/maced-contract-canary.yml new file mode 100644 index 000000000..ab1d9675b --- /dev/null +++ b/.github/workflows/maced-contract-canary.yml @@ -0,0 +1,35 @@ +name: Maced contract canary + +on: + pull_request: + paths: + - 'apps/api/src/security-penetration-tests/**' + - 'apps/api/test/maced-contract.e2e-spec.ts' + - 'apps/api/package.json' + - '.github/workflows/maced-contract-canary.yml' + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + maced-contract-canary: + runs-on: warp-ubuntu-latest-arm64-4x + timeout-minutes: 15 + env: + MACED_API_KEY: ${{ secrets.MACED_API_KEY }} + MACED_CONTRACT_E2E_RUN_ID: ${{ secrets.MACED_CONTRACT_E2E_RUN_ID }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dangerous-git-checkout + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run Maced provider contract canary + working-directory: ./apps/api + run: bun run test:e2e:maced diff --git a/apps/api/package.json b/apps/api/package.json index 11bedd133..7202037f9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -126,6 +126,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e:maced": "MACED_CONTRACT_E2E=1 jest --config ./test/jest-e2e.json --runInBand ./maced-contract.e2e-spec.ts", "test:watch": "jest --watch", "typecheck": "tsc --noEmit" } diff --git a/apps/api/src/security-penetration-tests/README.md b/apps/api/src/security-penetration-tests/README.md index 234c01d2c..db8ed0651 100644 --- a/apps/api/src/security-penetration-tests/README.md +++ b/apps/api/src/security-penetration-tests/README.md @@ -38,3 +38,14 @@ This module exposes Comp API endpoints under `/v1/security-penetration-tests` an - 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. + +## Maced contract canary test (real provider) + +Use this e2e canary to detect Maced API contract drift against the live provider without creating new paid runs. + +- Test file: `apps/api/test/maced-contract.e2e-spec.ts` +- Command: + - `MACED_API_KEY= bun run test:e2e:maced` +- Optional deep-check env: + - `MACED_CONTRACT_E2E_RUN_ID=` + - When present, the test also calls `GET /v1/pentests/:id` and `GET /v1/pentests/:id/progress`. diff --git a/apps/api/src/security-penetration-tests/maced-client.ts b/apps/api/src/security-penetration-tests/maced-client.ts index efcf7353f..2876a7f79 100644 --- a/apps/api/src/security-penetration-tests/maced-client.ts +++ b/apps/api/src/security-penetration-tests/maced-client.ts @@ -17,36 +17,48 @@ const macedPentestStatusSchema = z.enum([ 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 nonEmptyStringSchema = z.string().trim().min(1); +const nonEmptyDateTimeSchema = nonEmptyStringSchema.datetime(); + +const normalizeBlankToNull = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const nullableNonEmptyStringSchema = z + .preprocess(normalizeBlankToNull, nonEmptyStringSchema.nullable().optional()) + .transform((value) => value ?? null); + +const nullableUrlSchema = z + .preprocess(normalizeBlankToNull, z.string().url().nullable().optional()) + .transform((value) => value ?? null); + const macedPentestRunSchema = z .object({ - id: z.string().min(1), - sandboxId: z.string().min(1), - workflowId: z.string().min(1), - sessionId: z.string().min(1), + id: nonEmptyStringSchema, targetUrl: z.string().url(), - repoUrl: z.string().url().nullable().optional(), + repoUrl: nullableUrlSchema, 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(), + createdAt: nonEmptyDateTimeSchema, + updatedAt: nonEmptyDateTimeSchema, + error: nullableNonEmptyStringSchema, + temporalUiUrl: nullableUrlSchema, + webhookUrl: nullableUrlSchema, + notificationEmail: nullableNonEmptyStringSchema, }) .passthrough(); +const macedCreatePentestRunSchema = macedPentestRunSchema.extend({ + webhookToken: nullableNonEmptyStringSchema, +}); + const macedPentestRunWithProgressSchema = macedPentestRunSchema.extend({ progress: macedPentestProgressSchema, }); @@ -63,13 +75,14 @@ const macedCreatePentestPayloadSchema = z testMode: z.boolean().optional(), workspace: z.string().optional(), webhookUrl: z.string().url().optional(), - mockCheckout: z.boolean().optional(), + notificationEmail: z.string().email().optional(), }) .strict(); export type MacedPentestStatus = z.infer; export type MacedPentestProgress = z.infer; export type MacedPentestRun = z.infer; +export type MacedCreatePentestRun = z.infer; export type MacedPentestRunWithProgress = z.infer< typeof macedPentestRunWithProgressSchema >; @@ -215,7 +228,7 @@ export class MacedClient { ); } - async createPentest(payload: MacedCreatePentestPayload): Promise { + async createPentest(payload: MacedCreatePentestPayload): Promise { const validatedPayload = macedCreatePentestPayloadSchema.safeParse(payload); if (!validatedPayload.success) { this.logger.error( @@ -234,7 +247,7 @@ export class MacedClient { method: 'POST', body: JSON.stringify(validatedPayload.data), }, - macedPentestRunSchema, + macedCreatePentestRunSchema, 'creating penetration test', ); } 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 index e96d00525..7fafa7cab 100644 --- a/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts +++ b/apps/api/src/security-penetration-tests/security-penetration-tests.service.ts @@ -10,7 +10,12 @@ 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'; +import { + MacedClient, + type MacedCreatePentestRun, + type MacedPentestProgress, + type MacedPentestRun, +} from './maced-client'; export type PentestReportStatus = | 'provisioning' @@ -24,21 +29,17 @@ export type PentestProgress = MacedPentestProgress; export interface SecurityPenetrationTest { id: string; - sandboxId: string; - workflowId: string; - sessionId: string; targetUrl: string; - repoUrl: string | null; + repoUrl?: string | null; status: PentestReportStatus; testMode?: boolean | null; createdAt: string; updatedAt: string; error?: string | null; + failedReason?: string | null; temporalUiUrl?: string | null; webhookUrl?: string | null; - webhookToken?: string | null; - userId: string; - organizationId: string; + notificationEmail?: string | null; progress?: PentestProgress; } @@ -49,7 +50,7 @@ export interface BinaryArtifact { } interface PentestCompletedWebhookPayload { - id: string; + runId: string; report: { markdown: string; costUsd: number; @@ -59,7 +60,7 @@ interface PentestCompletedWebhookPayload { } interface PentestFailedWebhookPayload { - id: string; + runId: string; error: string; failedAt: string; } @@ -108,7 +109,7 @@ export class SecurityPenetrationTestsService { return reports.filter((report) => { return ownedRunIds.has(report.id); - }) as SecurityPenetrationTest[]; + }).map((report) => this.mapMacedRunToSecurityPenetrationTest(report)); } async createReport( @@ -125,7 +126,6 @@ export class SecurityPenetrationTestsService { pipelineTesting: payload.pipelineTesting, testMode: payload.testMode, workspace: payload.workspace, - mockCheckout: payload.mockCheckout, webhookUrl: resolvedWebhookUrl, }; @@ -190,13 +190,13 @@ export class SecurityPenetrationTestsService { ); } - return createdReport as SecurityPenetrationTest; + return this.mapMacedRunToSecurityPenetrationTest(createdReport); } async getReport(organizationId: string, id: string): Promise { await this.assertRunOwnership(organizationId, id); const report = await this.macedClient.getPentest(id); - return report as SecurityPenetrationTest; + return this.mapMacedRunToSecurityPenetrationTest(report); } async getReportProgress( @@ -262,9 +262,9 @@ export class SecurityPenetrationTestsService { const failedEvent = this.extractFailedWebhookPayload(payload); const payloadReportId = - completedEvent?.id ?? - failedEvent?.id ?? - this.extractStringField(payload, 'id'); + completedEvent?.runId ?? + failedEvent?.runId ?? + this.extractStringField(payload, 'runId'); if (!payloadReportId) { throw new BadRequestException('Webhook payload must include a report id'); @@ -329,6 +329,17 @@ export class SecurityPenetrationTestsService { return value.slice(0, end); } + private mapMacedRunToSecurityPenetrationTest( + report: MacedPentestRun | MacedCreatePentestRun, + ): SecurityPenetrationTest { + const failedReason = report.error ?? null; + + return { + ...(report as SecurityPenetrationTest), + failedReason, + }; + } + private isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -415,7 +426,7 @@ export class SecurityPenetrationTestsService { return null; } - const reportId = this.extractStringField(payload, 'id'); + const reportId = this.extractStringField(payload, 'runId'); const reportValue = payload.report; const isReportRecord = this.isRecord(reportValue); @@ -440,7 +451,7 @@ export class SecurityPenetrationTestsService { } return { - id: reportId, + runId: reportId, report: { markdown, costUsd, @@ -457,7 +468,7 @@ export class SecurityPenetrationTestsService { return null; } - const reportId = this.extractStringField(payload, 'id'); + const reportId = this.extractStringField(payload, 'runId'); const error = this.extractStringField(payload, 'error'); const failedAt = this.extractStringField(payload, 'failedAt'); @@ -466,7 +477,7 @@ export class SecurityPenetrationTestsService { } return { - id: reportId, + runId: reportId, error, failedAt, }; diff --git a/apps/api/test/maced-contract.e2e-spec.ts b/apps/api/test/maced-contract.e2e-spec.ts new file mode 100644 index 000000000..dd158935d --- /dev/null +++ b/apps/api/test/maced-contract.e2e-spec.ts @@ -0,0 +1,87 @@ +import { MacedClient, type MacedPentestRun } from '../src/security-penetration-tests/maced-client'; + +const enabledValues = new Set(['1', 'true', 'yes']); +const isContractCanaryEnabled = enabledValues.has( + (process.env.MACED_CONTRACT_E2E ?? '').toLowerCase(), +); + +const describeIfEnabled = isContractCanaryEnabled ? describe : describe.skip; + +const validStatuses = new Set([ + 'provisioning', + 'cloning', + 'running', + 'completed', + 'failed', + 'cancelled', +]); + +describeIfEnabled('Maced provider contract canary (e2e)', () => { + let client: MacedClient; + + beforeAll(() => { + if (!process.env.MACED_API_KEY) { + throw new Error( + 'MACED_API_KEY is required when MACED_CONTRACT_E2E is enabled', + ); + } + + client = new MacedClient(); + }); + + const assertRunShape = (run: MacedPentestRun) => { + expect(typeof run.id).toBe('string'); + expect(run.id.length).toBeGreaterThan(0); + expect(typeof run.targetUrl).toBe('string'); + expect(() => new URL(run.targetUrl)).not.toThrow(); + expect(validStatuses.has(run.status)).toBe(true); + expect(Number.isNaN(Date.parse(run.createdAt))).toBe(false); + expect(Number.isNaN(Date.parse(run.updatedAt))).toBe(false); + + if (run.repoUrl) { + expect(() => new URL(run.repoUrl)).not.toThrow(); + } + + if (run.temporalUiUrl) { + expect(() => new URL(run.temporalUiUrl)).not.toThrow(); + } + + if (run.webhookUrl) { + expect(() => new URL(run.webhookUrl)).not.toThrow(); + } + }; + + it('lists runs and validates canonical response shape', async () => { + const runs = await client.listPentests(); + + expect(Array.isArray(runs)).toBe(true); + for (const run of runs) { + assertRunShape(run); + } + }); + + const runIdForDeepChecks = process.env.MACED_CONTRACT_E2E_RUN_ID; + const itIfRunIdProvided = runIdForDeepChecks ? it : it.skip; + + itIfRunIdProvided( + 'fetches canonical run detail and progress for provided run id', + async () => { + const runId = runIdForDeepChecks as string; + + const run = await client.getPentest(runId); + assertRunShape(run); + expect(run.id).toBe(runId); + expect(typeof run.progress.status).toBe('string'); + expect(validStatuses.has(run.progress.status)).toBe(true); + expect(run.progress.completedAgents).toBeGreaterThanOrEqual(0); + expect(run.progress.totalAgents).toBeGreaterThanOrEqual(0); + expect(run.progress.elapsedMs).toBeGreaterThanOrEqual(0); + + const progress = await client.getPentestProgress(runId); + expect(validStatuses.has(progress.status)).toBe(true); + expect(progress.completedAgents).toBeGreaterThanOrEqual(0); + expect(progress.totalAgents).toBeGreaterThanOrEqual(0); + expect(progress.elapsedMs).toBeGreaterThanOrEqual(0); + }, + ); +}); 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 index 8d91573ac..7cb7d1853 100644 --- 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 @@ -95,9 +95,6 @@ describe('PenetrationTestPageClient', () => { 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', @@ -106,8 +103,6 @@ describe('PenetrationTestPageClient', () => { error: null, temporalUiUrl: null, webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }; reportMock.mockReturnValue({ @@ -131,46 +126,9 @@ describe('PenetrationTestPageClient', () => { 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', @@ -179,8 +137,6 @@ describe('PenetrationTestPageClient', () => { error: null, temporalUiUrl: null, webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }; reportMock.mockReturnValue({ @@ -203,9 +159,6 @@ describe('PenetrationTestPageClient', () => { 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', @@ -214,8 +167,6 @@ describe('PenetrationTestPageClient', () => { error: null, temporalUiUrl: null, webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }; reportMock.mockReturnValue({ @@ -227,8 +178,6 @@ describe('PenetrationTestPageClient', () => { progressMock.mockReturnValue({ progress: { status: 'running', - phase: 'scan', - agent: null, completedAgents: 1, totalAgents: 2, elapsedMs: 300, @@ -240,16 +189,13 @@ describe('PenetrationTestPageClient', () => { expect(screen.getByText('Running')).toBeInTheDocument(); expect(screen.getByText('Current progress')).toBeInTheDocument(); - expect(screen.getByText('scan (1/2)')).toBeInTheDocument(); + expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); expect(screen.queryByText('Download PDF')).toBeNull(); }); - it('renders progress fallback text without phase and counts when data is incomplete', async () => { + it('renders progress fallback text when agent counts are unavailable', 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', @@ -258,8 +204,6 @@ describe('PenetrationTestPageClient', () => { error: null, temporalUiUrl: null, webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }; reportMock.mockReturnValue({ @@ -271,8 +215,6 @@ describe('PenetrationTestPageClient', () => { progressMock.mockReturnValue({ progress: { status: 'running', - phase: null, - agent: null, completedAgents: '1' as unknown as number, totalAgents: '2' as unknown as number, elapsedMs: 400, @@ -289,9 +231,6 @@ describe('PenetrationTestPageClient', () => { 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', @@ -300,8 +239,6 @@ describe('PenetrationTestPageClient', () => { error: 'Scan failed due to provider timeout', temporalUiUrl: 'https://temporal.ui/session', webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }; reportMock.mockReturnValue({ 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 index 3e82e327b..50f2e0cd5 100644 --- 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 @@ -77,6 +77,7 @@ export function PenetrationTestPageClient({ orgId, reportId }: PenetrationTestPa const isInProgress = isReportInProgress(report.status); const safeTemporalUiUrl = report.temporalUiUrl ? toSafeExternalHttpUrl(report.temporalUiUrl) : null; + const runFailureReason = report.failedReason ?? report.error ?? null; const openArtifact = async (path: string, filename?: string): Promise => { try { @@ -159,16 +160,12 @@ export function PenetrationTestPageClient({ orgId, reportId }: PenetrationTestPa

Last update

{formatReportDate(report.updatedAt)}

-
-

Sandbox

-

{report.sandboxId || '—'}

-
- {report.error && ( + {runFailureReason && (

Run error

-

{report.error}

+

{runFailureReason}

)} @@ -176,11 +173,7 @@ export function PenetrationTestPageClient({ orgId, reportId }: PenetrationTestPa

Current progress

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

) : null} diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx index 43af921a8..39734219f 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.test.tsx @@ -52,32 +52,22 @@ describe('use-penetration-tests hooks', () => { 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', }, ]), ); @@ -124,23 +114,16 @@ describe('use-penetration-tests hooks', () => { 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, @@ -159,7 +142,7 @@ describe('use-penetration-tests hooks', () => { 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'); + expect(progress.result.current.progress?.completedAgents).toBe(1); }); it('loads a report detail for empty id only when both identifiers are present', async () => { diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts index f0e739836..f5b42805e 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/hooks/use-penetration-tests.ts @@ -18,6 +18,19 @@ const reportEndpoint = (reportId: string): string => `/v1/security-penetration-tests/${encodeURIComponent(reportId)}`; const reportProgressEndpoint = (reportId: string): string => `/v1/security-penetration-tests/${encodeURIComponent(reportId)}/progress`; +const inProgressStatus: readonly PentestReportStatus[] = [ + 'provisioning', + 'cloning', + 'running', +]; +const allStatuses: readonly PentestReportStatus[] = [ + 'provisioning', + 'cloning', + 'running', + 'completed', + 'failed', + 'cancelled', +]; type ReportsSWRKey = readonly [endpoint: string, organizationId: string]; @@ -40,6 +53,18 @@ async function fetchApiJson([endpoint, organizationId]: ReportsSWRKey): Promi return (response.data ?? null) as T; } +const resolveCreateStatus = ( + status: string | undefined, +): PentestReportStatus => { + if (!status) { + return 'provisioning'; + } + + return (allStatuses as readonly string[]).includes(status) + ? (status as PentestReportStatus) + : 'provisioning'; +}; + interface CreatePayload { targetUrl: string; repoUrl?: string; @@ -190,7 +215,7 @@ export function useCreatePenetrationTest( id?: string; checkoutMode?: 'mock' | 'stripe'; checkoutUrl?: string; - status?: string; + status?: PentestReportStatus; }>( reportListEndpoint, { @@ -236,15 +261,45 @@ export function useCreatePenetrationTest( checkoutUrl, }; + const now = new Date().toISOString(); + const optimisticReport: PentestRun = { + id: reportId, + targetUrl: payload.targetUrl, + repoUrl: payload.repoUrl ?? null, + status: resolveCreateStatus(response.data?.status), + testMode: payload.testMode ?? null, + createdAt: now, + updatedAt: now, + error: null, + failedReason: null, + temporalUiUrl: null, + webhookUrl: null, + }; + setIsCreating(false); try { - await mutate(reportListKey(organizationId)); - } catch (revalidateError) { + await mutate( + reportListKey(organizationId), + (currentReports?: PentestRun[]) => { + const nextReports = currentReports ?? []; + const dedupedReports = nextReports.filter(({ id }) => id !== reportId); + return sortReportsByUpdatedAtDesc([optimisticReport, ...dedupedReports]); + }, + { revalidate: false }, + ); + await mutate( + reportKey(organizationId, reportId), + optimisticReport, + { revalidate: false }, + ); + } catch (cacheMutationError) { console.error( - 'Created penetration test but failed to refresh report list', - revalidateError, + 'Created penetration test but failed to optimistically update report cache', + cacheMutationError, ); } + void mutate(reportListKey(organizationId)); + void mutate(reportKey(organizationId, reportId)); return data; } catch (reportError) { const message = diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx index 34f59ca86..67c4b5c54 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.test.tsx @@ -112,9 +112,6 @@ vi.mock('@trycompai/design-system', () => ({ 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', @@ -123,14 +120,9 @@ const reportRows: PentestRun[] = [ 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', @@ -139,8 +131,6 @@ const reportRows: PentestRun[] = [ error: null, temporalUiUrl: null, webhookUrl: null, - userId: 'user_1', - organizationId: 'org_123', }, ]; @@ -262,63 +252,31 @@ describe('PenetrationTestsPageClient', () => { expect(screen.getByText('2 completed reports')).toBeInTheDocument(); }); - it('uses fallback progress phase text when phase is missing', () => { + it('shows in-progress text with agent counts for a running report', () => { + const runningWithProgress: PentestRun = { + id: 'run_with_progress', + targetUrl: 'https://running.example.com', + repoUrl: 'https://github.com/org/running', + status: 'running', + createdAt: '2026-02-26T14:00:00Z', + updatedAt: '2026-02-26T14:30:00Z', + error: null, + temporalUiUrl: null, + webhookUrl: null, + progress: { + status: 'running', + completedAgents: 0, + totalAgents: 2, + elapsedMs: 500, + }, + }; + 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, - }, - }, - ], + reports: [runningWithProgress], 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, - }, - }, - ], + activeReports: [runningWithProgress], completedReports: [], }); @@ -346,62 +304,30 @@ describe('PenetrationTestsPageClient', () => { }); it('renders repository fallback when repoUrl is not available', () => { + const noRepoRun: PentestRun = { + id: 'run_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, + progress: { + status: 'running', + completedAgents: 1, + totalAgents: 2, + elapsedMs: 1000, + }, + }; + 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, - }, - }, - ], + reports: [noRepoRun], 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, - }, - }, - ], + activeReports: [noRepoRun], completedReports: [], }); @@ -525,130 +451,70 @@ describe('PenetrationTestsPageClient', () => { }); }); - it('renders progress for running report rows including phase and agent counts', () => { + it('renders progress for running report rows with agent counts', () => { + const inProgressRun: PentestRun = { + id: 'run_in_progress', + 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, + progress: { + status: 'running', + completedAgents: 1, + totalAgents: 2, + elapsedMs: 1500, + }, + }; + 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, - }, - }, - ], + reports: [inProgressRun], 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, - }, - }, - ], + activeReports: [inProgressRun], completedReports: [], }); render(); - expect(screen.getByText('scan (1/2)')).toBeInTheDocument(); + expect(screen.getByText('In progress (1/2)')).toBeInTheDocument(); }); - it('renders progress row without completed/total counts when values are unavailable', () => { + it('renders progress row without counts when agent count values are unavailable', () => { + const noCounts: PentestRun = { + id: 'run_without_counts', + 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, + progress: { + status: 'running', + completedAgents: 'n/a' as unknown as number, + totalAgents: 'n/a' as unknown as number, + elapsedMs: 0, + }, + }; + 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, - }, - }, - ], + reports: [noCounts], 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, - }, - }, - ], + activeReports: [noCounts], completedReports: [], }); render(); - expect(screen.getByText('initializing')).toBeInTheDocument(); + expect(screen.getByText('In progress')).toBeInTheDocument(); expect(screen.queryByText('(n/a/n/a)')).toBeNull(); }); diff --git a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx index 27678efe5..ca7d135e8 100644 --- a/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx +++ b/apps/app/src/app/(app)/[orgId]/security/penetration-tests/penetration-tests-page-client.tsx @@ -237,14 +237,14 @@ export function PenetrationTestsPageClient({ orgId }: PenetrationTestsPageClient {report.progress ? ( - {report.progress.phase ?? 'In progress'} - {typeof report.progress.completedAgents === 'number' && - typeof report.progress.totalAgents === 'number' - ? ` (${report.progress.completedAgents}/${report.progress.totalAgents})` - : ''} + {`In progress${typeof report.progress.completedAgents === 'number' && typeof report.progress.totalAgents === 'number' ? ` (${report.progress.completedAgents}/${report.progress.totalAgents})` : ''}`} ) : isReportInProgress(report.status) ? ( In queue + ) : report.status === 'failed' ? ( + + {report.failedReason ?? report.error ?? 'Run failed'} + ) : ( )} diff --git a/apps/app/src/lib/security/penetration-tests-client.ts b/apps/app/src/lib/security/penetration-tests-client.ts index 01e304738..9f880d156 100644 --- a/apps/app/src/lib/security/penetration-tests-client.ts +++ b/apps/app/src/lib/security/penetration-tests-client.ts @@ -2,8 +2,6 @@ export type PentestReportStatus = 'provisioning' | 'cloning' | 'running' | 'comp export interface PentestProgress { status: PentestReportStatus; - phase: string | null; - agent: string | null; completedAgents: number; totalAgents: number; elapsedMs: number; @@ -11,9 +9,6 @@ export interface PentestProgress { export interface PentestRun { id: string; - sandboxId: string; - workflowId: string; - sessionId: string; targetUrl: string; repoUrl?: string | null; status: PentestReportStatus; @@ -21,10 +16,10 @@ export interface PentestRun { createdAt: string; updatedAt: string; error?: string | null; + failedReason?: string | null; temporalUiUrl?: string | null; webhookUrl?: string | null; - userId: string; - organizationId: string; + notificationEmail?: string | null; progress?: PentestProgress; } @@ -37,12 +32,13 @@ export interface PentestCreateRequest { testMode?: boolean; workspace?: string; webhookUrl?: string; + notificationEmail?: string; mockCheckout?: boolean; } export interface CreatePenetrationTestResponse { checkoutMode?: 'mock' | 'stripe'; id: string; - status?: string; + status?: PentestReportStatus; checkoutUrl: string; } From f23df39be66c04a733e3c38c8233ee7551ee09cd Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:56:16 -0500 Subject: [PATCH 03/12] feat: consolidate automation failure emails into daily digest (#2216) * feat: add bulk automation failure notification endpoint and email template Add POST /v1/internal/tasks/notify-bulk-automation-failures endpoint with FailedTaskDto and NotifyBulkAutomationFailuresDto DTOs, add notifyBulkAutomationFailures service method (recipients: task assignees + org admins/owners), and add consolidated email template that lists failed tasks with (X/Y failed) counts capped at 15 items. * fix: remove unused title and assigneeId fields from bulk failures DB query --- .../templates/automation-bulk-failures.tsx | 143 ++++++++++++ .../internal-task-notification.controller.ts | 65 +++++- apps/api/src/tasks/task-notifier.service.ts | 209 ++++++++++++++++++ 3 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/email/templates/automation-bulk-failures.tsx diff --git a/apps/api/src/email/templates/automation-bulk-failures.tsx b/apps/api/src/email/templates/automation-bulk-failures.tsx new file mode 100644 index 000000000..a9f77ea9a --- /dev/null +++ b/apps/api/src/email/templates/automation-bulk-failures.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; +import { getUnsubscribeUrl } from '@trycompai/email'; + +interface FailedTaskItem { + title: string; + url: string; + failedCount: number; + totalCount: number; +} + +interface Props { + toName: string; + toEmail: string; + organizationName: string; + tasksUrl: string; + tasks: FailedTaskItem[]; +} + +const MAX_DISPLAYED_TASKS = 15; + +export const AutomationBulkFailuresEmail = ({ + toName, + toEmail, + organizationName, + tasksUrl, + tasks, +}: Props) => { + const unsubscribeUrl = getUnsubscribeUrl(toEmail); + const taskCount = tasks.length; + const taskText = taskCount === 1 ? 'task' : 'tasks'; + const displayedTasks = tasks.slice(0, MAX_DISPLAYED_TASKS); + const remainingCount = taskCount - displayedTasks.length; + + return ( + + + + + + + + {`${taskCount} ${taskText} with automation failures`} + + + + + + + Automation Failures Summary + + + + Hello {toName}, + + + + Today's scheduled automations found failures in{' '} + {taskCount} {taskText} in{' '} + {organizationName}. + + +
+ {displayedTasks.map((task, index) => ( + + {'• '} + + {task.title} + + {' '}({task.failedCount}/{task.totalCount} failed) + + ))} + {remainingCount > 0 && ( + + and {remainingCount} more... + + )} +
+ +
+ +
+ + + or copy and paste this URL into your browser:{' '} + + {tasksUrl} + + + +
+ + Don't want to receive task assignment notifications?{' '} + + Manage your email preferences + + . + +
+ +
+ +