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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user