Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@aws-sdk/client-sts": "^3.948.0",
"@aws-sdk/client-transfer": "^3.948.0",
"@aws-sdk/client-wafv2": "^3.948.0",
"@aws-sdk/lib-storage": "3.1013.0",
"@aws-sdk/s3-request-presigner": "3.1013.0",
"@browserbasehq/sdk": "2.6.0",
"@browserbasehq/stagehand": "^3.2.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Mocks must be declared before any SUT import so guards' transitive deps
// (Prisma, better-auth) don't instantiate in Jest.
const mockTrigger = jest.fn();
jest.mock('@trigger.dev/sdk', () => ({
tasks: { trigger: mockTrigger },
}));

jest.mock('@db', () => ({
...jest.requireActual('@prisma/client'),
db: {},
Expand Down Expand Up @@ -225,18 +230,15 @@ describe('EvidenceExportController', () => {

describe('AuditorEvidenceExportController', () => {
let controller: AuditorEvidenceExportController;
let service: jest.Mocked<
Pick<EvidenceExportService, 'streamOrganizationEvidenceZip'>
>;

beforeEach(async () => {
service = {
streamOrganizationEvidenceZip: jest.fn(),
};
mockTrigger.mockReset().mockResolvedValue({
id: 'run_123',
publicAccessToken: 'tok_abc',
});

const moduleRef = await Test.createTestingModule({
controllers: [AuditorEvidenceExportController],
providers: [{ provide: EvidenceExportService, useValue: service }],
})
.overrideGuard(HybridAuthGuard)
.useValue({ canActivate: () => true })
Expand All @@ -247,35 +249,35 @@ describe('AuditorEvidenceExportController', () => {
controller = moduleRef.get(AuditorEvidenceExportController);
});

it('pipes the org-wide archive to response with correct headers', async () => {
const archive = makeFakeArchive();
service.streamOrganizationEvidenceZip.mockResolvedValue({
archive: archive as unknown as import('archiver').Archiver,
filename: 'acme_all-evidence_2026-04-22.zip',
});
const req = makeFakeRequest();
const res = makeFakeResponse();

await controller.exportAllEvidence(
'org_1',
'true',
req as unknown as import('express').Request,
res as unknown as import('express').Response,
it('triggers a background task and returns runId + token', async () => {
const result = await controller.exportAllEvidence('org_1', 'true');

expect(mockTrigger).toHaveBeenCalledWith(
'export-organization-evidence',
{ organizationId: 'org_1', includeJson: true },
{
concurrencyKey: 'org_1',
idempotencyKey: 'evidence-export:org_1:true',
idempotencyKeyTTL: '30m',
},
);
expect(result).toEqual({
runId: 'run_123',
publicAccessToken: 'tok_abc',
});
});

expect(service.streamOrganizationEvidenceZip).toHaveBeenCalledWith(
'org_1',
{ includeRawJson: true },
it('serializes per-org and dedupes on org + includeJson (includeJson=false)', async () => {
await controller.exportAllEvidence('org_2', undefined as unknown as string);

expect(mockTrigger).toHaveBeenCalledWith(
'export-organization-evidence',
{ organizationId: 'org_2', includeJson: false },
{
concurrencyKey: 'org_2',
idempotencyKey: 'evidence-export:org_2:false',
idempotencyKeyTTL: '30m',
},
);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Type',
'application/zip',
);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Disposition',
`attachment; filename="acme_all-evidence_2026-04-22.zip"`,
);
expect(res.flushHeaders).toHaveBeenCalledTimes(1);
expect(archive.pipe).toHaveBeenCalledWith(res);
});
});
71 changes: 29 additions & 42 deletions apps/api/src/tasks/evidence-export/evidence-export.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Controller,
Get,
Post,
Param,
Query,
Req,
Expand All @@ -16,6 +17,7 @@ import {
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { tasks } from '@trigger.dev/sdk';
import type { Request, Response } from 'express';
import type { Archiver } from 'archiver';
import { AuditRead } from '../../audit/skip-audit-log.decorator';
Expand Down Expand Up @@ -205,7 +207,8 @@ export class EvidenceExportController {
}

/**
* Auditor-only controller for bulk evidence export
* Auditor-only controller for bulk evidence export.
* The heavy work runs in a Trigger.dev background task to avoid OOM in the API.
*/
@ApiTags('Evidence Export (Auditor)')
@Controller({ path: 'evidence-export', version: '1' })
Expand All @@ -214,18 +217,13 @@ export class EvidenceExportController {
export class AuditorEvidenceExportController {
private readonly logger = new Logger(AuditorEvidenceExportController.name);

constructor(private readonly evidenceExportService: EvidenceExportService) {}

/**
* Export all evidence for the organization (auditor only)
*/
@Get('all')
@Post('all')
@RequirePermission('evidence', 'read')
@AuditRead()
@ApiOperation({
summary: 'Export all organization evidence as ZIP (Auditor only)',
summary: 'Trigger bulk evidence export (Auditor only)',
description:
'Generate and download a ZIP file containing all automation evidence across all tasks. Only accessible by auditors.',
'Starts a background job that generates a ZIP of all evidence. Returns a run ID for progress tracking.',
})
@ApiQuery({
name: 'includeJson',
Expand All @@ -234,49 +232,38 @@ export class AuditorEvidenceExportController {
required: false,
})
@ApiResponse({
status: 200,
description: 'ZIP file generated successfully',
content: {
'application/zip': {},
},
})
@ApiResponse({
status: 403,
description: 'Access denied - Auditor role required',
status: 201,
description: 'Export job started',
})
async exportAllEvidence(
@OrganizationId() organizationId: string,
@Query('includeJson') includeJson: string,
@Req() req: Request,
@Res() res: Response,
) {
this.logger.log('Auditor exporting all evidence', {
const includeJsonBool = includeJson === 'true';
this.logger.log('Auditor triggering bulk evidence export', {
organizationId,
includeJson: includeJson === 'true',
includeJson: includeJsonBool,
});

const { archive, filename } =
await this.evidenceExportService.streamOrganizationEvidenceZip(
organizationId,
{ includeRawJson: includeJson === 'true' },
);

res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="${filename}"`,
const handle = await tasks.trigger(
'export-organization-evidence',
{ organizationId, includeJson: includeJsonBool },
{
// Serialize exports per org (the task queue's concurrencyLimit is 1, and
// concurrencyKey gives each org its own lane) so a burst of clicks can
// never run multiple heavy exports for the same org at once.
concurrencyKey: organizationId,
// Collapse rapid duplicate triggers (double-click / retry) for the same
// org + options into a single run for the TTL window.
idempotencyKey: `evidence-export:${organizationId}:${includeJsonBool}`,
idempotencyKeyTTL: '30m',
},
);
// See note on the task variant above — flush early so a slow first task
// doesn't blow past the proxy idle timeout for large orgs.
res.flushHeaders();

pipeArchiveToResponse({
archive,
req,
res,
logger: this.logger,
tag: `org ${organizationId}`,
});
return {
runId: handle.id,
publicAccessToken: handle.publicAccessToken,
};
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { EventEmitter } from 'node:events';

// Mock module boundaries so importing the task does not connect to Postgres,
// the Trigger SDK, S3, or pull in jsPDF. We only exercise streamArchiveToS3 —
// the concurrent populate+upload orchestration and its error propagation.
jest.mock('@db', () => ({ db: { organization: { findUnique: jest.fn() } } }));

jest.mock('@trigger.dev/sdk', () => ({
metadata: { set: jest.fn(), get: jest.fn() },
schemaTask: (config: unknown) => config,
}));

const mockUploadDone = jest.fn();
const mockUploadAbort = jest.fn().mockResolvedValue(undefined);
jest.mock('@aws-sdk/lib-storage', () => ({
Upload: jest.fn().mockImplementation((opts: { params?: { Body?: unknown } }) => {
// A real Upload consumes the Body stream and surfaces its errors via done().
// Mimic that so a destroyed PassThrough doesn't become an unhandled error.
const body = opts?.params?.Body as
| { on?: (e: string, cb: () => void) => void; resume?: () => void }
| undefined;
if (body && typeof body.on === 'function') {
body.on('error', () => {});
if (typeof body.resume === 'function') body.resume();
}
return { done: mockUploadDone, abort: mockUploadAbort };
}),
}));

jest.mock('@aws-sdk/client-s3', () => ({
S3Client: jest.fn(),
GetObjectCommand: jest.fn(),
}));
jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn() }));

// Fake archiver: an EventEmitter with the methods streamArchiveToS3 touches.
function makeFakeArchive() {
const emitter = new EventEmitter();
return Object.assign(emitter, {
pipe: jest.fn(),
finalize: jest.fn().mockResolvedValue(undefined),
abort: jest.fn(),
append: jest.fn(),
});
}
let fakeArchive: ReturnType<typeof makeFakeArchive>;
jest.mock('archiver', () => jest.fn(() => fakeArchive));

// The task module pulls these in at import time; stub them (unused by the SUT).
jest.mock('@/tasks/evidence-export/evidence-data-loader', () => ({
getAutomationHeaders: jest.fn(),
streamAutomationRuns: jest.fn(),
findTasksWithEvidence: jest.fn(),
}));
jest.mock('@/tasks/evidence-export/evidence-pdf-generator', () => ({
generateAutomationPDFFromStream: jest.fn(),
generateTaskSummaryPDF: jest.fn(),
sanitizeFilename: (s: string) => s,
}));
jest.mock('@/tasks/evidence-export/evidence-json-builder', () => ({
buildAutomationJsonStream: jest.fn(),
}));
jest.mock('@/tasks/evidence-export/evidence-attachment-streamer', () => ({
getTaskAttachments: jest.fn(),
appendAttachmentToArchive: jest.fn(),
createFilenameTracker: jest.fn(),
}));

import { streamArchiveToS3 } from './export-organization-evidence';

describe('streamArchiveToS3', () => {
const s3Client = {} as never;
const baseParams = { s3Client, bucket: 'b', key: 'k' };

beforeEach(() => {
fakeArchive = makeFakeArchive();
mockUploadDone.mockReset().mockResolvedValue(undefined);
mockUploadAbort.mockClear();
});

it('finalizes the archive once and resolves on the happy path', async () => {
const populate = jest.fn().mockResolvedValue(undefined);

await streamArchiveToS3({ ...baseParams, populate });

expect(populate).toHaveBeenCalledWith(fakeArchive);
expect(fakeArchive.finalize).toHaveBeenCalledTimes(1);
expect(fakeArchive.pipe).toHaveBeenCalledTimes(1);
expect(mockUploadDone).toHaveBeenCalledTimes(1);
expect(mockUploadAbort).not.toHaveBeenCalled();
});

it('aborts the archive + upload and rethrows when populate fails', async () => {
const boom = new Error('populate failed');
const populate = jest.fn().mockRejectedValue(boom);
// Upload would hang until the stream ends; resolve it so allSettled settles.
mockUploadDone.mockResolvedValue(undefined);

await expect(streamArchiveToS3({ ...baseParams, populate })).rejects.toBe(
boom,
);

expect(fakeArchive.abort).toHaveBeenCalledTimes(1);
expect(fakeArchive.finalize).not.toHaveBeenCalled();
expect(mockUploadAbort).toHaveBeenCalledTimes(1);
});

it('aborts the multipart upload and rethrows when the S3 upload fails', async () => {
const uploadErr = new Error('s3 upload failed');
const populate = jest.fn().mockResolvedValue(undefined);
mockUploadDone.mockRejectedValue(uploadErr);

await expect(streamArchiveToS3({ ...baseParams, populate })).rejects.toBe(
uploadErr,
);

expect(fakeArchive.finalize).toHaveBeenCalledTimes(1);
// populate succeeded, so we do not abort the archive itself...
expect(fakeArchive.abort).not.toHaveBeenCalled();
// ...but we DO cancel the multipart upload to avoid orphaned S3 parts.
expect(mockUploadAbort).toHaveBeenCalledTimes(1);
});
});
Loading
Loading