Specs unitarios bajo src/app/core/**: cifrado de secretos, filtro global de excepciones, guards, interceptor de respuesta, procesado de media, resolución de permisos, storage S3 y utilidades.
Resumen: 14 archivos spec, 14 suites de primer nivel (describe), 94 bloques it()/it.each() declarados. Los it.each se ejecutan una vez por fila (ver tablas).
src/app/core/crypto/secret-encryption.spec.ts
Cifrado AES-GCM de secretos con clave de 32 bytes en base64. No usa mocks de módulos; manipula process.env (se limpia en afterEach).
decodeEncryptionKey
| Test | Escenario | Resultado esperado | Mocks |
|---|
decodes a valid base64-encoded 32-byte key | Se decodifica una clave base64 válida de 32 bytes | Devuelve un Buffer de longitud 32 | Ninguno |
names the env var when the key is missing | Valor undefined | Lanza error con TEST_SECRET_ENCRYPTION_KEY is not set | Ninguno |
names the env var when the key is an empty string | Valor '' | Lanza error ... is not set | Ninguno |
rejects a key shorter than 32 bytes | Clave de 16 bytes | Error must decode to exactly 32 bytes (got 16) | Ninguno |
rejects a key longer than 32 bytes | Clave de 48 bytes | Error must decode to exactly 32 bytes (got 48) | Ninguno |
readEncryptionKey
| Test | Escenario | Resultado esperado | Mocks |
|---|
reads and decodes the named env var | La variable de entorno contiene una clave válida | Devuelve el Buffer igual a la clave | process.env |
fails at the point of use when the env var is missing | Variable no definida | Lanza ... is not set al leerla | process.env |
isValidEncryptionKey
| Test | Escenario | Resultado esperado | Mocks |
|---|
accepts a 32-byte key and rejects anything else | Clave válida, clave de 16 bytes, '' y undefined | true para la válida; false para el resto | Ninguno |
encryptSecret / decryptSecret
| Test | Escenario | Resultado esperado | Mocks |
|---|
round-trips a plaintext string exactly | Se cifra y descifra un JSON serializado | El sobre es válido (isEncryptedEnvelope) y el descifrado coincide con el original | Ninguno |
uses a fresh IV on every call | Se cifra dos veces el mismo texto | iv y ciphertext difieren entre llamadas | Ninguno |
never stores the plaintext verbatim inside the envelope | Se cifra un token | El JSON del sobre no contiene el texto plano | Ninguno |
throws when the ciphertext is tampered with | Se reemplaza el ciphertext por otro | Lanza authentication tag verification failed | Ninguno |
throws when the auth tag is tampered with | Se altera el primer byte del tag | Lanza authentication tag verification failed | Ninguno |
throws when decrypted with a different key | Se descifra con otra clave válida | Lanza authentication tag verification failed | Ninguno |
isEncryptedEnvelope
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns true for a real envelope | Sobre generado por encryptSecret | true | Ninguno |
returns false for legacy plaintext-shaped data | Objeto { type: 'Buffer', data: [1, 2, 3] } | false | Ninguno |
returns false for null, undefined, and primitives | null, undefined, string y número | false en todos | Ninguno |
returns false for an object missing one of the three fields | Sobre sin tag | false | Ninguno |
src/app/core/filters/all-exceptions.filter.spec.ts
Filtro global de excepciones: oculta errores internos y conserva los controlados.
AllExceptionsFilter — unhandled exceptions
| Test | Escenario | Resultado esperado | Mocks |
|---|
responds 500 with a generic message and never leaks the internal error | Se lanza un Error con detalle de MongoDB (E11000 ...) | Status 500 y body { statusCode: 500, status: 'error', message: 'Internal server error', data: {} }; el JSON no contiene E11000 | ArgumentsHost falso (status/json), Logger.prototype.error |
keeps the internal message and stack in the server log | Se lanza Error('connection refused 10.0.0.5:27017') | Logger.error recibe [UnhandledException] <mensaje> y el stack | Logger.prototype.error |
responds 500 with the generic message when a non-Error value is thrown | Se lanza un objeto plano que no es Error | Status 500 con el mismo body genérico | ArgumentsHost, Logger |
AllExceptionsFilter — controlled exceptions
| Test | Escenario | Resultado esperado | Mocks |
|---|
preserves the HttpException message and fail status for 4xx | NotFoundException('Usuario no encontrado') | Status 404 y body con status: 'fail', el mensaje original y data: {} | ArgumentsHost, Logger |
preserves the AppException body as-is | AppException('Ticket bloqueado', 409, { ticketId: 't-1' }) | Status 409 y body con mensaje, status: 'fail' y data: { ticketId: 't-1' } | ArgumentsHost, Logger |
src/app/core/guards/api-key.guard.spec.ts
Guard de API key en el body: valida hash, estado, expiración, tenant y whitelist de dominios.
ApiKeyGuard — missing apiKey in body
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw UnauthorizedException when body has no apiKey field | Body sin campo apiKey | Rechaza con UnauthorizedException | ApiKeyService, TenantService |
should throw UnauthorizedException when body is empty | Body {} | Rechaza con UnauthorizedException | ApiKeyService, TenantService |
ApiKeyGuard — invalid key
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw UnauthorizedException when no record matches the hash | findByCode devuelve null | UnauthorizedException | ApiKeyService.findByCode |
ApiKeyGuard — inactive key
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw UnauthorizedException when isActive is false | Registro con isActive: false | UnauthorizedException | ApiKeyService.findByCode |
ApiKeyGuard — expired key
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw UnauthorizedException when dateExpiration is in the past | dateExpiration anterior a ahora | UnauthorizedException | ApiKeyService.findByCode |
ApiKeyGuard — tenant validation
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw UnauthorizedException when tenant does not exist | findByMongoId devuelve null | UnauthorizedException | ApiKeyService, TenantService.findByMongoId |
should throw UnauthorizedException when tenant isActive is false | Tenant con isActive: false | UnauthorizedException | ApiKeyService, TenantService |
ApiKeyGuard — domain whitelist
El tenant de estos casos tiene domainWhitelist: ['acme.com'].
| Test | Escenario | Resultado esperado | Mocks |
|---|
should throw ForbiddenException when origin is not in whitelist | Header origin: https://evil.com | ForbiddenException | ApiKeyService, TenantService |
should throw ForbiddenException when no origin/referer and whitelist is set | Sin origin ni referer | ForbiddenException | ApiKeyService, TenantService |
should allow when origin hostname ends with a whitelisted domain | origin: https://app.acme.com | Resuelve true | ApiKeyService, TenantService |
should allow when referer matches (no origin header) | Solo referer: https://acme.com/page | Resuelve true | ApiKeyService, TenantService |
ApiKeyGuard — body stripping
| Test | Escenario | Resultado esperado | Mocks |
|---|
should remove apiKey from body before passing to the handler | Body { apiKey, name, age } | req.body queda { name: 'John', age: 30 } sin apiKey | ApiKeyService, TenantService |
should leave body empty object when apiKey was the only field | Body solo con apiKey | req.body es {} | ApiKeyService, TenantService |
ApiKeyGuard — successful validation
| Test | Escenario | Resultado esperado | Mocks |
|---|
should return true and attach tenant and apiKey record to request | Key, tenant y body válidos | Devuelve true; req.tenant y req.apiKey quedan adjuntos | ApiKeyService, TenantService |
should call findByCode with SHA-256 hash of the raw key | Key válida en texto plano | findByCode se invoca con el hash y nunca con la key cruda | ApiKeyService, TenantService |
src/app/core/guards/auth.guard.spec.ts
Guard JWT (header x-api-key): verifica token, usuario activo y tenant operativo.
AuthGuard
| Test | Escenario | Resultado esperado | Mocks |
|---|
attaches the user when token, user and tenant are valid | Token válido, usuario activo, tenant operativo | Resuelve true, request.user es el usuario y se llama hasOperationalTenant(tenantId) | JwtService.verify, UserService.findByEmail/hasOperationalTenant |
rejects a user whose tenant is not operational | hasOperationalTenant devuelve false | UnauthorizedException y request.user sin definir | JwtService, UserService |
rejects a request without token before querying users | Sin headers | UnauthorizedException; no se consulta findByEmail | JwtService, UserService |
rejects an invalid or expired token | verify lanza jwt expired | UnauthorizedException; no se consulta findByEmail | JwtService, UserService |
rejects an unknown user | findByEmail devuelve null | UnauthorizedException; no se valida el tenant | JwtService, UserService |
rejects an inactive user without checking the tenant | Usuario con isActive: false | UnauthorizedException; no se valida el tenant | JwtService, UserService |
src/app/core/guards/permissions.guard.spec.ts
Guard basado en @RequirePermission; resuelve permisos del rol vía PermissionResolverService.
PermissionsGuard
| Test | Escenario | Resultado esperado | Mocks |
|---|
allows access when the handler has no @RequirePermission metadata | Handler sin decorador | Resuelve true; el resolver no se invoca | PermissionResolverService.resolve, Reflector real |
rejects when the request has no user attached | Request sin user en handler protegido | ForbiddenException | PermissionResolverService |
rejects when the resolved acronym set does not include the required permission | El set resuelto es CONTACT_READ, se exige CONTACT_CREATE | ForbiddenException; resolve llamado con (tenantId, roleId) | PermissionResolverService.resolve |
allows access when the resolved acronym set includes the required permission | El set resuelto incluye CONTACT_CREATE | Resuelve true | PermissionResolverService.resolve |
| Test | Escenario | Resultado esperado | Mocks |
|---|
allows a user flagged as platform admin | user.isPlatformAdmin === true | Devuelve true | Contexto HTTP falso |
rejects %s (it.each, 4 filas) | Filas: tenant admin sin el flag (userType: 'admin'); flag en false; flag truthy no booleano ('true'); request sin user | Lanza ForbiddenException en cada fila | Contexto HTTP falso |
src/app/core/guards/roles.guard.spec.ts
Guard basado en @Roles, compara con user.userType.
RolesGuard
| Test | Escenario | Resultado esperado | Mocks |
|---|
allows access when the handler has no @Roles metadata | Handler sin decorador, usuario agent | Devuelve true | Reflector real, contexto falso |
rejects a user whose userType is not in the required roles | Handler @Roles('admin'), usuario agent | ForbiddenException | Contexto falso |
rejects when the request has no user attached | Handler protegido sin user | ForbiddenException | Contexto falso |
allows a user whose userType matches the required role | Handler @Roles('admin'), usuario admin | Devuelve true | Contexto falso |
src/app/core/interceptors/response.interceptor.spec.ts
Normaliza las respuestas; en GET garantiza { results, meta } sin perder claves propias del endpoint (p. ej. ids, unreadCount).
ResponseInterceptor — @RawResponse routes
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns the handler payload untouched, not wrapped | Handler decorado con @RawResponse devuelve 'challenge-123' | La respuesta es exactamente 'challenge-123' | ExecutionContext, CallHandler (of(payload)) |
keeps wrapping handlers without the decorator | POST con { message, data: { a: 1 } } | Respuesta con status: 'success' y data: { a: 1 } | ExecutionContext, CallHandler |
ResponseInterceptor — GET
| Test | Escenario | Resultado esperado | Mocks |
|---|
keeps the endpoint own keys alongside results/meta | data: { ids: [...] } | data.ids se conserva; results es [] y meta es {} | ExecutionContext, CallHandler |
keeps a scalar payload such as unreadCount | data: { unreadCount: 52 } | data.unreadCount es 52 | ExecutionContext, CallHandler |
passes a normal paginated list through untouched | data: { results, meta } | data es igual a { results, meta } | ExecutionContext, CallHandler |
defaults results/meta when the controller returns nothing | El controlador devuelve undefined | data es { results: [], meta: {} } y message es 'OK' | ExecutionContext, CallHandler |
never lets an own key overwrite results or meta | data: { results: [...], ids: [...] } | results e ids se preservan sin sobrescribirse | ExecutionContext, CallHandler |
wraps with the response status and a success flag | data: { results: [] } con mensaje 'listo' | statusCode 200, status: 'success', message: 'listo' | ExecutionContext, CallHandler |
ResponseInterceptor — non-GET
| Test | Escenario | Resultado esperado | Mocks |
|---|
leaves the payload data as the controller returned it | POST con data anidada | res.data igual al data original | ExecutionContext, CallHandler |
falls back to an empty object when there is no data | PATCH sin data | res.data es {} | ExecutionContext, CallHandler |
| Test | Escenario | Resultado esperado | Mocks |
|---|
converts a valid input buffer to a WEBP buffer/mimetype/ext | Buffer JPEG de 200x200 generado con sharp | mimetype image/webp, ext .webp, buffer no vacío y metadata format webp (timeout 15 s) | Ninguno (usa sharp real) |
src/app/core/permissions/permission-resolver.service.spec.ts
Resuelve el set de acrónimos de permisos por tenant+rol con caché en memoria (TTL de 10 s).
PermissionResolverService
| Test | Escenario | Resultado esperado | Mocks |
|---|
resolves the acronym set from the database on a cache miss | Sin caché; el modelo devuelve dos permisos | find({ tenantId, roleId }), populate('permissions', 'acronym') y set con ambos acrónimos | Modelo RolePermission (find/populate/exec) |
returns an empty set when the role has no permission grants | El modelo devuelve [] | Set vacío | Modelo RolePermission |
does not query the database again on a cache hit within the TTL | Dos resolve consecutivos | find se llama una sola vez y el segundo resultado es igual | Modelo RolePermission |
re-queries the database once the cache entry has expired past the TTL | Reloj falso: se avanza a 10001 ms y cambian los permisos | find se llama dos veces y se devuelve el set actualizado | Modelo RolePermission, jest.useFakeTimers |
clears the cache entry for a tenant+role when invalidate is called | Se llama invalidate entre dos resolve | find se llama dos veces | Modelo RolePermission |
keeps separate cache entries per tenant+role combination | Se resuelve con dos tenants distintos y el mismo rol | find se llama dos veces (cachés separadas) | Modelo RolePermission |
src/app/core/storage/storage.service.spec.ts
Servicio S3/MinIO. El bucket es privado salvo el prefijo public/ (avatares), el guardián del criterio CA7.
StorageService — onModuleInit
| Test | Escenario | Resultado esperado | Mocks |
|---|
creates the bucket when it does not exist | HeadBucketCommand falla con NotFound | Se envían HeadBucketCommand y CreateBucketCommand | S3Client.send, ConfigService |
applies the public-read policy even when the bucket already exists | El bucket existe | Se envía exactamente una PutBucketPolicyCommand | S3Client.send, ConfigService |
opens ONLY the public prefix, never the whole bucket | Se inspecciona la policy enviada | El único recurso es arn:aws:s3:::crm-files/public/*; no incluye bucket/* ni el bucket completo | S3Client.send, ConfigService |
grants read only — never write or delete — to the anonymous principal | Se inspeccionan los statements | La única acción es s3:GetObject | S3Client.send, ConfigService |
starts anyway when the policy cannot be applied | PutBucketPolicy falla con AccessDenied | onModuleInit resuelve sin error (undefined) | S3Client.send, ConfigService |
StorageService — getSignedUrl
| Test | Escenario | Resultado esperado | Mocks |
|---|
delegates to the s3-request-presigner with a GetObjectCommand and a 24h expiration | Se pide URL firmada para tenant/image/uuid.jpg | Devuelve la URL del presigner, llamado con un GetObjectCommand y expiresIn de 86400 s | getSignedUrl (presigner), S3Client |
StorageService — upload
| Test | Escenario | Resultado esperado | Mocks |
|---|
uploads the object and returns a url string (not directly usable without signing, since the bucket is private) | Se sube un buffer JPEG | Devuelve endpoint/bucket/key | S3Client.send, ConfigService |
StorageService — uploadPublic
| Test | Escenario | Resultado esperado | Mocks |
|---|
prefixes the key so the object falls under the public policy | Se sube avatars/tenant/contact.jpg | La URL devuelta contiene el prefijo public/ | S3Client.send, ConfigService |
src/app/core/utils/message-signature.utils.spec.ts
signAgentBody
| Test | Escenario | Resultado esperado | Mocks |
|---|
firma los mensajes de agente y de operador con la firma en su propia línea | Tipos AGENT y USER con nombre | Devuelve *Nombre*:\nHola | Ninguno |
no firma al contacto ni a los mensajes de sistema | Tipos CONTACT y SYSTEM | El body queda intacto | Ninguno |
es idempotente: un body ya firmado no se firma dos veces | Se firma un body ya firmado | Devuelve el mismo body firmado | Ninguno |
devuelve el body intacto sin nombre utilizable o sin texto | Nombre en blanco, null o body vacío | Body original sin cambios ('' se mantiene) | Ninguno |
src/app/core/utils/tenant.utils.spec.ts
isTenantOperational
| Test | Escenario | Resultado esperado | Mocks |
|---|
accepts an active tenant with active status | { isActive: true, status: 'active' } | true | Ninguno |
rejects a tenant %s (it.each, 4 filas) | Filas: desactivado (isActive: false); con status inactive; sin status; sin isActive | false en cada fila | Ninguno |
rejects %p (it.each, 2 filas) | null y undefined | false | Ninguno |
toStringArray
| Test | Escenario | Resultado esperado | Mocks |
|---|
returns undefined for undefined/null | Valor undefined y null | undefined | Ninguno |
returns undefined for an empty string | '' | undefined | Ninguno |
returns undefined for an empty array | [] | undefined | Ninguno |
returns undefined when the array only contains empty strings | [''] | undefined | Ninguno |
wraps a single scalar value into a one-item array | Un string con un ObjectId | Array de un elemento | Ninguno |
returns the array as-is when given multiple distinct values | Dos ids distintos | El mismo array | Ninguno |
dedupes repeated values while preserving first-seen order | Ids con uno repetido | Array sin duplicados en orden de aparición | Ninguno |
trims whitespace and drops empty entries mixed with valid ones | [' id ', ''] | ['id'] recortado y sin vacíos | Ninguno |
Casos it() documentados en esta página: 94 declaraciones (it/it.each): 18 + 5 + 15 + 6 + 4 + 2 + 4 + 10 + 1 + 6 + 8 + 4 + 3 + 8.