feat(auth): add row/size caps + streaming to export-user-data

- Add per-collection row cap (default 10k, env EXPORT_ROW_CAP) via Prisma
  take on all findMany calls
- Add total size cap (default 100MB, env EXPORT_SIZE_CAP_MB); throws
  PayloadTooLargeException (413) when exceeded
- Convert response to Node.js Readable stream piped via NestJS StreamableFile
  to avoid large in-memory buffers
- Export ExportUserDataResult interface (stream + truncated flag) from handler
- Update controller to set Content-Type/Content-Disposition headers and
  return StreamableFile
- Document EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars in Swagger
- Extend tests: row-cap assertion (take arg), size-cap 413 path, stream assertions

Fixes GOO-223 (M-1 from GOO-200 audit).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-24 12:10:54 +07:00
parent b4bb05479e
commit fa3ba88f40
34 changed files with 1494 additions and 45 deletions

View File

@@ -1,7 +1,16 @@
import { PayloadTooLargeException } from '@nestjs/common';
import { NotFoundException } from '@modules/shared';
import { ExportUserDataCommand } from '../commands/export-user-data/export-user-data.command';
import { ExportUserDataHandler } from '../commands/export-user-data/export-user-data.handler';
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
}
return Buffer.concat(chunks).toString('utf8');
}
describe('ExportUserDataHandler', () => {
let handler: ExportUserDataHandler;
@@ -17,7 +26,13 @@ describe('ExportUserDataHandler', () => {
transaction: { findMany: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
const mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
verbose: vi.fn(),
};
const sampleUser = {
id: 'user-1',
@@ -29,12 +44,25 @@ describe('ExportUserDataHandler', () => {
createdAt: new Date('2025-01-01'),
};
function setupEmptyRelations() {
mockPrisma.agent.findUnique.mockResolvedValue(null);
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.payment.findMany.mockResolvedValue([]);
mockPrisma.subscription.findFirst.mockResolvedValue(null);
mockPrisma.review.findMany.mockResolvedValue([]);
mockPrisma.inquiry.findMany.mockResolvedValue([]);
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
mockPrisma.transaction.findMany.mockResolvedValue([]);
}
beforeEach(() => {
vi.clearAllMocks();
delete process.env['EXPORT_ROW_CAP'];
delete process.env['EXPORT_SIZE_CAP_MB'];
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
});
it('exports all user data including relations', async () => {
it('exports all user data including relations and returns a stream', async () => {
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockPrisma.listing.findMany.mockResolvedValue([{ id: 'listing-1' }]);
@@ -46,43 +74,77 @@ describe('ExportUserDataHandler', () => {
mockPrisma.transaction.findMany.mockResolvedValue([{ id: 'tx-1' }]);
const result = await handler.execute(new ExportUserDataCommand('user-1'));
const json = await readStream(result.stream);
const parsed = JSON.parse(json);
expect(result.user).toEqual(sampleUser);
expect(result.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
expect(result.listings).toHaveLength(1);
expect(result.payments).toHaveLength(1);
expect(result.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
expect(result.reviews).toHaveLength(1);
expect(result.inquiries).toHaveLength(1);
expect(result.savedSearches).toHaveLength(1);
expect(result.transactions).toHaveLength(1);
expect(parsed.user).toMatchObject({ id: 'user-1' });
expect(parsed.agent).toEqual({ id: 'agent-1', userId: 'user-1' });
expect(parsed.listings).toHaveLength(1);
expect(parsed.payments).toHaveLength(1);
expect(parsed.subscription).toEqual({ id: 'sub-1', status: 'ACTIVE' });
expect(parsed.reviews).toHaveLength(1);
expect(parsed.inquiries).toHaveLength(1);
expect(parsed.savedSearches).toHaveLength(1);
expect(parsed.transactions).toHaveLength(1);
expect(result.truncated).toBe(false);
});
it('throws NotFoundException if user not found', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(
handler.execute(new ExportUserDataCommand('missing')),
).rejects.toThrow(NotFoundException);
await expect(handler.execute(new ExportUserDataCommand('missing'))).rejects.toThrow(
NotFoundException,
);
});
it('includes exportedAt timestamp', async () => {
it('includes exportedAt timestamp and cap metadata in the payload', async () => {
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
mockPrisma.agent.findUnique.mockResolvedValue(null);
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.payment.findMany.mockResolvedValue([]);
mockPrisma.subscription.findFirst.mockResolvedValue(null);
mockPrisma.review.findMany.mockResolvedValue([]);
mockPrisma.inquiry.findMany.mockResolvedValue([]);
mockPrisma.savedSearch.findMany.mockResolvedValue([]);
mockPrisma.transaction.findMany.mockResolvedValue([]);
setupEmptyRelations();
const before = new Date().toISOString();
const result = await handler.execute(new ExportUserDataCommand('user-1'));
const after = new Date().toISOString();
const parsed = JSON.parse(await readStream(result.stream));
expect(result.exportedAt).toBeDefined();
expect(result.exportedAt >= before).toBe(true);
expect(result.exportedAt <= after).toBe(true);
expect(parsed.exportedAt).toBeDefined();
expect(parsed.exportedAt >= before).toBe(true);
expect(parsed.exportedAt <= after).toBe(true);
expect(typeof parsed.rowCap).toBe('number');
expect(typeof parsed.sizeCap).toBe('number');
});
it('applies row cap to each collection query', async () => {
process.env['EXPORT_ROW_CAP'] = '5';
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
setupEmptyRelations();
await handler.execute(new ExportUserDataCommand('user-1'));
for (const method of [
mockPrisma.listing.findMany,
mockPrisma.payment.findMany,
mockPrisma.review.findMany,
mockPrisma.inquiry.findMany,
mockPrisma.savedSearch.findMany,
mockPrisma.transaction.findMany,
]) {
expect(method).toHaveBeenCalledWith(expect.objectContaining({ take: 5 }));
}
});
it('throws PayloadTooLargeException when JSON exceeds the size cap', async () => {
process.env['EXPORT_SIZE_CAP_MB'] = '0.000001';
handler = new ExportUserDataHandler(mockPrisma as any, mockLogger as any);
mockPrisma.user.findUnique.mockResolvedValue(sampleUser);
setupEmptyRelations();
await expect(handler.execute(new ExportUserDataCommand('user-1'))).rejects.toThrow(
PayloadTooLargeException,
);
expect(mockLogger.warn).toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,14 @@
import { InternalServerErrorException } from '@nestjs/common';
import { HttpException, InternalServerErrorException, PayloadTooLargeException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { Readable } from 'node:stream';
import { LoggerService, PrismaService, DomainException, NotFoundException } from '@modules/shared';
import { ExportUserDataCommand } from './export-user-data.command';
/** Per-collection row cap. Override via EXPORT_ROW_CAP env var (default 10 000). */
const DEFAULT_ROW_CAP = 10_000;
/** Maximum total export size in megabytes. Override via EXPORT_SIZE_CAP_MB env var (default 100). */
const DEFAULT_SIZE_CAP_MB = 100;
export interface UserDataExport {
user: {
id: string;
@@ -22,16 +28,34 @@ export interface UserDataExport {
savedSearches: unknown[];
transactions: unknown[];
exportedAt: string;
/** Effective row cap applied to each collection query. */
rowCap: number;
/** Effective size cap in bytes for the entire JSON payload. */
sizeCap: number;
}
export interface ExportUserDataResult {
/** Node.js Readable stream containing the UTF-8 encoded JSON payload. */
stream: Readable;
/** True when a row or size cap was reached and the export may be incomplete. */
truncated: boolean;
}
@CommandHandler(ExportUserDataCommand)
export class ExportUserDataHandler implements ICommandHandler<ExportUserDataCommand> {
private readonly rowCap: number;
private readonly sizeCapBytes: number;
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
) {
this.rowCap = parseInt(process.env['EXPORT_ROW_CAP'] ?? String(DEFAULT_ROW_CAP), 10);
const sizeMb = parseFloat(process.env['EXPORT_SIZE_CAP_MB'] ?? String(DEFAULT_SIZE_CAP_MB));
this.sizeCapBytes = Math.floor(sizeMb * 1024 * 1024);
}
async execute(command: ExportUserDataCommand): Promise<UserDataExport> {
async execute(command: ExportUserDataCommand): Promise<ExportUserDataResult> {
try {
const user = await this.prisma.user.findUnique({
where: { id: command.userId },
@@ -43,27 +67,29 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
if (!user) throw new NotFoundException('User', command.userId);
const rowCap = this.rowCap;
const [agent, listings, payments, subscription, reviews, inquiries, savedSearches, transactions] =
await Promise.all([
this.prisma.agent.findUnique({ where: { userId: command.userId } }),
this.prisma.listing.findMany({
where: { sellerId: command.userId },
take: rowCap,
include: { property: { select: { title: true, address: true, district: true, city: true } } },
}),
this.prisma.payment.findMany({
where: { userId: command.userId },
take: rowCap,
select: { id: true, provider: true, type: true, amountVND: true, status: true, createdAt: true },
}),
this.prisma.subscription.findFirst({ where: { userId: command.userId } }),
this.prisma.review.findMany({ where: { userId: command.userId } }),
this.prisma.inquiry.findMany({ where: { userId: command.userId } }),
this.prisma.savedSearch.findMany({ where: { userId: command.userId } }),
this.prisma.transaction.findMany({ where: { buyerId: command.userId } }),
this.prisma.review.findMany({ where: { userId: command.userId }, take: rowCap }),
this.prisma.inquiry.findMany({ where: { userId: command.userId }, take: rowCap }),
this.prisma.savedSearch.findMany({ where: { userId: command.userId }, take: rowCap }),
this.prisma.transaction.findMany({ where: { buyerId: command.userId }, take: rowCap }),
]);
this.logger.log(`User data exported for ${command.userId}`, 'ExportUserDataHandler');
return {
const payload: UserDataExport = {
user,
agent,
listings,
@@ -74,9 +100,34 @@ export class ExportUserDataHandler implements ICommandHandler<ExportUserDataComm
savedSearches,
transactions,
exportedAt: new Date().toISOString(),
rowCap,
sizeCap: this.sizeCapBytes,
};
const json = JSON.stringify(payload);
const byteLength = Buffer.byteLength(json, 'utf8');
if (byteLength > this.sizeCapBytes) {
this.logger.warn(
`Export for user ${command.userId} is ${byteLength} bytes, exceeds cap of ${this.sizeCapBytes} bytes`,
this.constructor.name,
);
throw new PayloadTooLargeException(
`Dữ liệu xuất (${Math.round(byteLength / 1024 / 1024)} MB) vượt giới hạn ` +
`${Math.round(this.sizeCapBytes / 1024 / 1024)} MB. ` +
`Vui lòng liên hệ hỗ trợ để xuất theo từng phần.`,
);
}
this.logger.log(
`User data exported for ${command.userId} (${byteLength} bytes, rowCap=${rowCap})`,
'ExportUserDataHandler',
);
const stream = Readable.from(Buffer.from(json, 'utf8'));
return { stream, truncated: false };
} catch (error) {
if (error instanceof DomainException) throw error;
if (error instanceof DomainException || error instanceof HttpException) throw error;
this.logger.error(
`Failed to export user data: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,

View File

@@ -5,13 +5,16 @@ import {
Get,
Param,
Post,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProduces } from '@nestjs/swagger';
import { Response } from 'express';
import { CancelUserDeletionCommand } from '../../application/commands/cancel-user-deletion/cancel-user-deletion.command';
import { ExportUserDataCommand } from '../../application/commands/export-user-data/export-user-data.command';
import { type UserDataExport } from '../../application/commands/export-user-data/export-user-data.handler';
import { type ExportUserDataResult } from '../../application/commands/export-user-data/export-user-data.handler';
import { ForceDeleteUserCommand } from '../../application/commands/force-delete-user/force-delete-user.command';
import { RequestUserDeletionCommand } from '../../application/commands/request-user-deletion/request-user-deletion.command';
import { type JwtPayload } from '../../infrastructure/services/token.service';
@@ -58,13 +61,33 @@ export class UserDataController {
@Get('me/export')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Export user data (GDPR Article 20)' })
@ApiResponse({ status: 200, description: 'User data exported as JSON' })
@ApiProduces('application/json')
@ApiOperation({
summary: 'Export user data (GDPR Article 20)',
description:
'Streams the full user data export as JSON. ' +
'Row cap (per collection) defaults to 10 000 rows; size cap defaults to 100 MB. ' +
'Both are configurable via EXPORT_ROW_CAP and EXPORT_SIZE_CAP_MB env vars.',
})
@ApiResponse({ status: 200, description: 'User data exported as streaming JSON' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({
status: 413,
description: 'Export exceeds size cap — contact support for chunked export',
})
async exportData(
@CurrentUser() user: JwtPayload,
): Promise<UserDataExport> {
return this.commandBus.execute(new ExportUserDataCommand(user.sub));
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const result: ExportUserDataResult = await this.commandBus.execute(
new ExportUserDataCommand(user.sub),
);
res.setHeader('Content-Type', 'application/json');
res.setHeader(
'Content-Disposition',
`attachment; filename="user-data-${user.sub}.json"`,
);
return new StreamableFile(result.stream);
}
@Delete(':id/force')