From feb3566f88ec1260a307e9ade6d4a0a0e4169575 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Valencia Londono <¨valencialondonojuansebastian@gmail.com¨> Date: Sun, 19 Jul 2026 22:00:51 -0500 Subject: [PATCH] feat(api): agregar validacion de entrada con class-validator - Agrega ValidationPipe global con whitelist, forbidNonWhitelisted y exceptionFactory personalizada - Define RegisterDto y LoginDto con decoradores de validacion (@IsEmail, @MinLength, @IsIn, @IsOptional) - Remueve guards manuales if (!email || !password) reemplazados por validacion automatica - Agrega tests unitarios al DTO con plainToInstance + validate --- apps/api/src/auth/auth.dto.spec.ts | 79 ++++++++++++++++ apps/api/src/auth/auth.service.ts | 141 ++++++++++++++++------------- apps/api/src/auth/dto/auth.dto.ts | 24 ++++- apps/api/src/bootstrap.ts | 57 ++++++++++-- 4 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 apps/api/src/auth/auth.dto.spec.ts diff --git a/apps/api/src/auth/auth.dto.spec.ts b/apps/api/src/auth/auth.dto.spec.ts new file mode 100644 index 0000000..811dd3b --- /dev/null +++ b/apps/api/src/auth/auth.dto.spec.ts @@ -0,0 +1,79 @@ +import { plainToInstance } from 'class-transformer'; +import { validate, ValidatorOptions } from 'class-validator'; +import { RegisterDto } from './dto/auth.dto'; + +const validatorOptions: ValidatorOptions = { + whitelist: true, + forbidNonWhitelisted: true, +}; + +describe('RegisterDto', () => { + it('rechaza email vacío / inválido', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'no-es-un-email', + password: '12345678', + perspectiva: 'usuario', + }); + const errors = await validate(dto, validatorOptions); + const props = errors.map((e) => e.property); + expect(props).toContain('email'); + }); + + it('rechaza password menor a 8 caracteres', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'user@velar.cr', + password: '1234', + perspectiva: 'usuario', + }); + const errors = await validate(dto, validatorOptions); + const props = errors.map((e) => e.property); + expect(props).toContain('password'); + }); + + it('rechaza perspectiva inválida', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'user@velar.cr', + password: '12345678', + perspectiva: 'admin', + }); + const errors = await validate(dto, validatorOptions); + const props = errors.map((e) => e.property); + expect(props).toContain('perspectiva'); + }); + + it('rechaza campos desconocidos con forbidNonWhitelisted', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'user@velar.cr', + password: '12345678', + perspectiva: 'usuario', + isAdmin: true, + }); + const errors = await validate(dto, validatorOptions); + const props = errors.map((e) => e.property); + expect(props).toContain('isAdmin'); + }); + + it('acepta payload válido — usuario', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'comprador@velar.cr', + password: 'Velar12345!', + perspectiva: 'usuario', + nombres: 'Juan', + apellidos: 'Pérez', + }); + const errors = await validate(dto, validatorOptions); + expect(errors).toHaveLength(0); + }); + + it('acepta payload válido — partido', async () => { + const dto = plainToInstance(RegisterDto, { + email: 'partido@velar.cr', + password: 'Velar12345!', + perspectiva: 'partido', + nombrePartido: 'Partido Velar', + codigo: 'PV', + }); + const errors = await validate(dto, validatorOptions); + expect(errors).toHaveLength(0); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 7edbd5f..eb1c7f6 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -1,30 +1,12 @@ -import { Injectable, BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + Logger, + UnauthorizedException, +} from '@nestjs/common'; import { SupabaseService } from '../common/supabase/supabase.service'; import { WalletService } from '../escrow/wallet.service'; - -export type Perspectiva = 'usuario' | 'partido' | 'tse'; - -export interface RegisterInput { - email: string; - password: string; - perspectiva: Perspectiva; - // Usuario (comprador/recomprador) - nombres?: string; - apellidos?: string; - identificacion?: string; - telefono?: string; - direccion?: string; - // Partido - nombrePartido?: string; - codigo?: string; - representanteLegal?: string; - cedulaJuridica?: string; -} - -export interface LoginInput { - email: string; - password: string; -} +import { RegisterDto, LoginDto } from './dto/auth.dto'; /** * Registro de cuentas con las 3 perspectivas: @@ -44,18 +26,16 @@ export class AuthService { private wallets: WalletService, ) {} - async login(input: LoginInput) { - if (!input.email || !input.password) { - throw new BadRequestException('email y password son obligatorios'); - } - + async login(input: LoginDto) { const { data, error } = await this.supabase.admin.auth.signInWithPassword({ email: input.email, password: input.password, }); if (error || !data.session) { - throw new UnauthorizedException(error?.message ?? 'Credenciales inválidas'); + throw new UnauthorizedException( + error?.message ?? 'Credenciales inválidas', + ); } return { @@ -67,10 +47,7 @@ export class AuthService { }; } - async register(input: RegisterInput) { - if (!input.email || !input.password) { - throw new BadRequestException('email y password son obligatorios'); - } + async register(input: RegisterDto) { const db = this.supabase.admin; // 1) Crear usuario de auth (confirmado, sin email de verificación para la demo). @@ -81,7 +58,9 @@ export class AuthService { user_metadata: { full_name: this.fullName(input) }, }); if (cErr || !created?.user) { - throw new BadRequestException(cErr?.message ?? 'No se pudo crear la cuenta'); + throw new BadRequestException( + cErr?.message ?? 'No se pudo crear la cuenta', + ); } const userId = created.user.id; @@ -94,18 +73,31 @@ export class AuthService { throw new BadRequestException('El partido requiere nombre y código'); } const full = { - code: input.codigo, name: input.nombrePartido, + code: input.codigo, + name: input.nombrePartido, representante_legal: input.representanteLegal ?? null, cedula_juridica: input.cedulaJuridica ?? null, }; let { data: party, error: pErr } = await db - .from('parties').upsert(full, { onConflict: 'code' }).select().single(); + .from('parties') + .upsert(full, { onConflict: 'code' }) + .select() + .single(); if (pErr && /column|schema cache/i.test(pErr.message)) { ({ data: party, error: pErr } = await db - .from('parties').upsert({ code: input.codigo, name: input.nombrePartido }, { onConflict: 'code' }).select().single()); + .from('parties') + .upsert( + { code: input.codigo, name: input.nombrePartido }, + { onConflict: 'code' }, + ) + .select() + .single()); } if (pErr) throw new BadRequestException(pErr.message); - const partyRow = party as { id: string; stellar_wallet?: string | null }; + const partyRow = party as { + id: string; + stellar_wallet?: string | null; + }; partyId = partyRow.id; partyWallet = partyRow.stellar_wallet ?? null; } @@ -115,10 +107,14 @@ export class AuthService { let walletStatus: string | null = partyWallet ? 'funded' : null; let walletNetwork: string | null = partyWallet ? 'testnet' : null; let walletError: string | null = null; - let walletCreatedAt: string | null = partyWallet ? new Date().toISOString() : null; + let walletCreatedAt: string | null = partyWallet + ? new Date().toISOString() + : null; if (!wallet) { try { - const createdWallet = await this.wallets.createWalletRecord(input.email); + const createdWallet = await this.wallets.createWalletRecord( + input.email, + ); wallet = createdWallet.publicKey; walletStatus = createdWallet.status; walletNetwork = createdWallet.network; @@ -132,22 +128,28 @@ export class AuthService { } if (partyId && wallet && !partyWallet) { try { - await db.from('parties').update({ - stellar_wallet: wallet, - stellar_wallet_status: walletStatus ?? 'created', - stellar_network: walletNetwork ?? 'testnet', - stellar_created_at: walletCreatedAt, - stellar_wallet_error: walletError, - }).eq('id', partyId); + await db + .from('parties') + .update({ + stellar_wallet: wallet, + stellar_wallet_status: walletStatus ?? 'created', + stellar_network: walletNetwork ?? 'testnet', + stellar_created_at: walletCreatedAt, + stellar_wallet_error: walletError, + }) + .eq('id', partyId); } catch { // Older schemas do not have party wallet metadata yet. } } // 4) Completar el profile (lo creó el trigger handle_new_user) con la info. - const role = input.perspectiva === 'partido' ? 'emisor' - : input.perspectiva === 'tse' ? 'tse' - : 'comprador'; + const role = + input.perspectiva === 'partido' + ? 'emisor' + : input.perspectiva === 'tse' + ? 'tse' + : 'comprador'; const core = { role, full_name: this.fullName(input), @@ -165,20 +167,35 @@ export class AuthService { telefono: input.telefono ?? null, direccion: input.direccion ?? null, }; - let { error: uErr } = await db.from('profiles').update({ ...core, ...extra }).eq('id', userId); + let { error: uErr } = await db + .from('profiles') + .update({ ...core, ...extra }) + .eq('id', userId); if (uErr && /column|schema cache/i.test(uErr.message)) { // La migración de campos de registro aún no se aplicó: guardamos lo básico. - this.logger.warn('Campos de registro no existen aún (aplicá la migración). Guardo lo básico.'); - ({ error: uErr } = await db.from('profiles').update({ - role, - full_name: this.fullName(input), - party_id: partyId, - stellar_wallet: wallet, - }).eq('id', userId)); + this.logger.warn( + 'Campos de registro no existen aún (aplicá la migración). Guardo lo básico.', + ); + ({ error: uErr } = await db + .from('profiles') + .update({ + role, + full_name: this.fullName(input), + party_id: partyId, + stellar_wallet: wallet, + }) + .eq('id', userId)); } if (uErr) throw new BadRequestException(uErr.message); - return { id: userId, email: input.email, role, perspectiva: input.perspectiva, partyId, wallet }; + return { + id: userId, + email: input.email, + role, + perspectiva: input.perspectiva, + partyId, + wallet, + }; } catch (e) { // Rollback: si algo falló luego de crear el auth user, lo borramos. await db.auth.admin.deleteUser(userId).catch(() => undefined); @@ -186,7 +203,7 @@ export class AuthService { } } - private fullName(i: RegisterInput): string { + private fullName(i: RegisterDto): string { if (i.perspectiva === 'partido') return i.nombrePartido ?? i.email; return [i.nombres, i.apellidos].filter(Boolean).join(' ') || i.email; } diff --git a/apps/api/src/auth/dto/auth.dto.ts b/apps/api/src/auth/dto/auth.dto.ts index 002007b..09cc2e3 100644 --- a/apps/api/src/auth/dto/auth.dto.ts +++ b/apps/api/src/auth/dto/auth.dto.ts @@ -7,7 +7,29 @@ import { IsString, MinLength, } from 'class-validator'; -import type { LoginInput, Perspectiva, RegisterInput } from '../auth.service'; + +export type Perspectiva = 'usuario' | 'partido' | 'tse'; + +interface RegisterInput { + email: string; + password: string; + perspectiva: Perspectiva; + nombres?: string; + apellidos?: string; + identificacion?: string; + telefono?: string; + direccion?: string; + // Partido + nombrePartido?: string; + codigo?: string; + representanteLegal?: string; + cedulaJuridica?: string; +} + +interface LoginInput { + email: string; + password: string; +} export class LoginDto implements LoginInput { @ApiProperty({ example: 'comprador@velar.cr' }) diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts index 727bf7d..ab0fb64 100644 --- a/apps/api/src/bootstrap.ts +++ b/apps/api/src/bootstrap.ts @@ -4,19 +4,24 @@ if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') { (globalThis as { WebSocket?: unknown }).WebSocket = WebSocket; } -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, BadRequestException } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter, NestExpressApplication } from '@nestjs/platform-express'; +import { + ExpressAdapter, + NestExpressApplication, +} from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import * as expressImport from 'express'; import type { Express } from 'express'; +import { ValidationError } from 'class-validator'; import { AppModule } from './app.module'; /** Express 5 en Vercel serverless exporta la factory sin `.default`; en local puede venir como default. */ const express: typeof expressImport = typeof expressImport === 'function' ? expressImport - : ((expressImport as { default?: typeof expressImport }).default ?? expressImport); + : ((expressImport as { default?: typeof expressImport }).default ?? + expressImport); let cachedApp: Express | null = null; @@ -25,15 +30,55 @@ export async function createNestExpressApp(): Promise { if (cachedApp) return cachedApp; const server = express(); - const app = await NestFactory.create(AppModule, new ExpressAdapter(server)); + const app = await NestFactory.create( + AppModule, + new ExpressAdapter(server), + ); app.set('trust proxy', 1); const corsOrigins = process.env.CORS_ORIGINS - ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean) + ? process.env.CORS_ORIGINS.split(',') + .map((s) => s.trim()) + .filter(Boolean) : [process.env.WEB_URL ?? 'http://localhost:3000']; app.enableCors({ origin: corsOrigins, credentials: true }); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + exceptionFactory: (validationErrors: ValidationError[]) => { + const extractErrors = ( + errors: ValidationError[], + parentPath = '', + ): Record => { + const result: Record = {}; + + for (const err of errors) { + const path = parentPath + ? `${parentPath}.${err.property}` + : err.property; + + if (err.constraints) { + result[path] = Object.values(err.constraints); + } + + if (err.children?.length) { + Object.assign(result, extractErrors(err.children, path)); + } + } + + return result; + }; + + return new BadRequestException({ + message: extractErrors(validationErrors), + error: 'Bad Request', + }); + }, + }), + ); app.setGlobalPrefix('api'); const swaggerConfig = new DocumentBuilder()