758 lines
21 KiB
Markdown
758 lines
21 KiB
Markdown
# RF-ADM-001: Gestión de Usuarios y Roles por Empresa/Obra
|
|
|
|
**ID:** RF-ADM-001
|
|
**Módulo:** MAI-013 - Administración & Seguridad
|
|
**Tipo:** Requerimiento Funcional
|
|
**Prioridad:** P0 (Crítica)
|
|
**Fecha de creación:** 2025-11-20
|
|
**Versión:** 1.0
|
|
|
|
---
|
|
|
|
## 📋 Descripción
|
|
|
|
El sistema debe permitir la **gestión completa de usuarios** con soporte multi-tenancy (múltiples empresas constructoras) y asignación de **7 roles especializados** en el sector construcción.
|
|
|
|
Cada usuario puede:
|
|
- Pertenecer a **una o más empresas constructoras** (multi-tenancy)
|
|
- Tener **diferentes roles** en cada empresa
|
|
- Acceder únicamente a **los datos de la empresa activa** (aislamiento total)
|
|
- Cambiar de empresa sin cerrar sesión
|
|
|
|
**Diferenciador vs GAMILIT:** GAMILIT tiene 3 roles académicos (student, teacher, admin); este sistema extiende a 7 roles especializados en construcción con permisos granulares por módulo.
|
|
|
|
---
|
|
|
|
## 🎯 Objetivos
|
|
|
|
### Objetivos de Negocio
|
|
|
|
1. **Multi-tenancy seguro:** Múltiples constructoras usan el mismo sistema sin ver datos ajenos
|
|
2. **Control de acceso por rol:** Cada usuario solo accede a funcionalidades según su rol
|
|
3. **Gestión centralizada:** Administradores gestionan usuarios desde un solo lugar
|
|
4. **Onboarding rápido:** Nuevos usuarios operan en < 5 minutos desde invitación
|
|
5. **Trazabilidad:** Auditoría completa de cambios en usuarios y permisos
|
|
|
|
### Objetivos Técnicos
|
|
|
|
1. **Row Level Security (RLS):** Filtrado automático de datos por `constructoraId`
|
|
2. **Invitación controlada:** No registro público, solo por invitación de administrador
|
|
3. **Estados de cuenta:** Manejo de usuarios activos, inactivos, suspendidos, bloqueados
|
|
4. **Validación en 3 capas:** Frontend → Backend → Database
|
|
5. **Performance:** Consultas de usuarios < 100ms
|
|
|
|
---
|
|
|
|
## 👥 Roles del Sistema
|
|
|
|
### 7 Roles Especializados
|
|
|
|
| Rol | Código | Nivel | Descripción Completa | Permisos Globales |
|
|
|-----|--------|-------|----------------------|-------------------|
|
|
| **Director General** | `director` | 🔴 Alto | Máxima autoridad de la empresa. Visión estratégica, aprobaciones finales, acceso total. | CRUD+Approve en todos los módulos |
|
|
| **Ingeniero/Planeación** | `engineer` | 🟠 Alto | Gestión técnica, planeación de obra, presupuestos, control de costos y avances. | CRUD en Proyectos, Presupuestos, Control Obra |
|
|
| **Residente de Obra** | `resident` | 🟡 Medio | Ejecución diaria en campo, supervisión de cuadrillas, captura de avances, evidencias. | CRUD en Control Obra, Compras, Avances |
|
|
| **Compras/Almacén** | `purchases` | 🟡 Medio | Gestión de compras, cotizaciones, órdenes de compra, inventarios, almacenes. | CRUD+Approve en Compras, Inventarios |
|
|
| **Administración/Finanzas** | `finance` | 🟠 Alto | Control financiero, flujo de efectivo, estimaciones, pagos, cuentas por cobrar/pagar. | CRUD+Approve en Estimaciones, Finanzas |
|
|
| **RRHH/Nómina** | `hr` | 🟡 Medio | Recursos humanos, asistencias, nómina, IMSS, INFONAVIT, control de personal. | CRUD+Approve en RRHH, Asistencias |
|
|
| **Postventa** | `post_sales` | 🟢 Bajo | Atención a clientes, garantías, calidad, CRM de derechohabientes. | CRUD en Postventa, Garantías, CRM |
|
|
|
|
### Comparación con GAMILIT
|
|
|
|
| GAMILIT | Inmobiliario | Cambios |
|
|
|---------|--------------|---------|
|
|
| `student` | - | No aplica (sistema B2B, no hay usuarios finales públicos) |
|
|
| `admin_teacher` | `engineer`, `resident`, `purchases`, `finance`, `hr` | Expansión a 5 roles especializados |
|
|
| `super_admin` | `director` | Similar, pero con permisos específicos de construcción |
|
|
|
|
---
|
|
|
|
## 🏢 Multi-Tenancy: Empresas Constructoras
|
|
|
|
### Modelo de Datos
|
|
|
|
```typescript
|
|
interface Constructora {
|
|
id: string; // UUID
|
|
code: string; // CONST-001, CONST-002 (auto-generado)
|
|
name: string; // "Constructora ABC S.A. de C.V."
|
|
legalName: string; // Razón social completa
|
|
rfc: string; // RFC (México)
|
|
taxRegime: string; // Régimen fiscal
|
|
|
|
// Domicilio fiscal
|
|
address: string;
|
|
city: string;
|
|
state: string;
|
|
zipCode: string;
|
|
country: string;
|
|
|
|
// Contacto
|
|
phone: string;
|
|
email: string;
|
|
website?: string;
|
|
|
|
// Logo y branding
|
|
logoUrl?: string;
|
|
primaryColor?: string; // Hex color
|
|
|
|
// Configuración
|
|
status: 'active' | 'inactive' | 'suspended';
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
### Relación Usuario - Constructora
|
|
|
|
Un usuario puede pertenecer a **múltiples constructoras** con **diferentes roles**:
|
|
|
|
```typescript
|
|
interface UserConstructora {
|
|
userId: string; // UUID
|
|
constructoraId: string; // UUID
|
|
role: ConstructionRole; // director | engineer | resident | etc.
|
|
status: 'active' | 'inactive';
|
|
joinedAt: Date;
|
|
|
|
// Permisos adicionales (opcional)
|
|
customPermissions?: {
|
|
module: string;
|
|
permissions: ('create' | 'read' | 'update' | 'delete' | 'approve')[];
|
|
}[];
|
|
}
|
|
```
|
|
|
|
**Ejemplo:**
|
|
|
|
```json
|
|
{
|
|
"userId": "uuid-123",
|
|
"userName": "Juan Pérez",
|
|
"constructoras": [
|
|
{
|
|
"constructoraId": "uuid-A",
|
|
"constructoraName": "Constructora ABC",
|
|
"role": "director",
|
|
"status": "active"
|
|
},
|
|
{
|
|
"constructoraId": "uuid-B",
|
|
"constructoraName": "Constructora XYZ",
|
|
"role": "engineer",
|
|
"status": "active"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Juan Pérez es **Director** en Constructora ABC y **Ingeniero** en Constructora XYZ.
|
|
|
|
### Selector de Constructora
|
|
|
|
Al hacer login, si el usuario pertenece a múltiples constructoras:
|
|
|
|
1. Se muestra un **selector** con las constructoras activas
|
|
2. Usuario selecciona constructora de trabajo
|
|
3. Se guarda `constructoraId` en el token JWT
|
|
4. Todos los queries se filtran por `constructoraId` (RLS)
|
|
5. Usuario puede **cambiar de constructora** desde el menú sin re-login
|
|
|
|
---
|
|
|
|
## 👤 Gestión de Usuarios
|
|
|
|
### Modelo de Datos Completo
|
|
|
|
```typescript
|
|
interface User {
|
|
// Identificación
|
|
id: string; // UUID
|
|
email: string; // Único a nivel sistema
|
|
password: string; // Hash bcrypt
|
|
|
|
// Datos personales
|
|
firstName: string;
|
|
lastName: string;
|
|
fullName: string; // Concatenación automática
|
|
phone?: string;
|
|
mobilePhone?: string;
|
|
|
|
// Avatar
|
|
avatarUrl?: string;
|
|
|
|
// Estado de cuenta
|
|
status: AccountStatus; // active | inactive | suspended | locked
|
|
emailVerified: boolean;
|
|
emailVerifiedAt?: Date;
|
|
|
|
// Seguridad
|
|
lastLoginAt?: Date;
|
|
lastLoginIp?: string;
|
|
failedLoginAttempts: number; // Bloqueo tras 5 intentos
|
|
lockedUntil?: Date; // Bloqueo temporal
|
|
passwordChangedAt?: Date;
|
|
mustChangePassword: boolean; // Primer login
|
|
|
|
// Metadata
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
createdBy?: string; // Usuario que creó la invitación
|
|
deletedAt?: Date; // Soft delete
|
|
}
|
|
```
|
|
|
|
### Estados de Cuenta
|
|
|
|
```typescript
|
|
enum AccountStatus {
|
|
ACTIVE = 'active', // Usuario activo, puede usar el sistema
|
|
INACTIVE = 'inactive', // Usuario dado de baja (puede reactivarse)
|
|
SUSPENDED = 'suspended', // Usuario temporalmente suspendido (sanción)
|
|
LOCKED = 'locked' // Bloqueado por intentos fallidos de login
|
|
}
|
|
```
|
|
|
|
#### Flujo de Estados
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> active: Invitación aceptada
|
|
active --> suspended: Suspender (temporal)
|
|
suspended --> active: Reactivar
|
|
active --> inactive: Dar de baja
|
|
inactive --> active: Reactivar
|
|
active --> locked: 5 intentos fallidos
|
|
locked --> active: Admin desbloquea o timeout (30 min)
|
|
```
|
|
|
|
### Proceso de Creación de Usuario (Invitación)
|
|
|
|
**No hay registro público.** Solo invitación por administrador:
|
|
|
|
1. **Admin crea invitación:**
|
|
- Email del nuevo usuario
|
|
- Rol a asignar
|
|
- Constructora(s) de pertenencia
|
|
- Mensaje personalizado (opcional)
|
|
|
|
2. **Sistema genera token único:**
|
|
- Token de invitación válido por 7 días
|
|
- Link: `https://app.ejemplo.com/register?token=abc123xyz`
|
|
|
|
3. **Email enviado:**
|
|
- Asunto: "Invitación a plataforma ERP - [Constructora ABC]"
|
|
- Contiene link de registro
|
|
|
|
4. **Usuario completa registro:**
|
|
- Click en link
|
|
- Valida token (no expirado)
|
|
- Completa perfil (nombre, teléfono, foto)
|
|
- Define contraseña (requisitos de seguridad)
|
|
- Acepta términos y condiciones
|
|
- **Primer login automático**
|
|
|
|
5. **Usuario activo:**
|
|
- Estado: `active`
|
|
- Rol asignado: según invitación
|
|
- `mustChangePassword: false`
|
|
|
|
### CRUD de Usuarios
|
|
|
|
#### Crear Usuario (solo por invitación)
|
|
|
|
**Endpoint:** `POST /api/admin/users/invite`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"email": "juan.perez@empresa.com",
|
|
"firstName": "Juan",
|
|
"lastName": "Pérez",
|
|
"constructoraId": "uuid-A",
|
|
"role": "engineer",
|
|
"customMessage": "Bienvenido al equipo de Proyecto Los Pinos"
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"invitationId": "uuid-inv-123",
|
|
"email": "juan.perez@empresa.com",
|
|
"token": "abc123xyz",
|
|
"expiresAt": "2025-11-27T23:59:59Z",
|
|
"invitationLink": "https://app.ejemplo.com/register?token=abc123xyz",
|
|
"emailSent": true
|
|
}
|
|
```
|
|
|
|
**Validaciones:**
|
|
- Email no existe previamente en el sistema
|
|
- Email con formato válido
|
|
- Rol válido (uno de los 7 roles)
|
|
- Constructora existe y está activa
|
|
- Usuario invitador tiene rol `director` o es super admin
|
|
|
|
#### Leer Usuarios
|
|
|
|
**Endpoint:** `GET /api/admin/users?constructoraId={uuid}&status={active|inactive|all}&role={role}&search={text}`
|
|
|
|
**Filters:**
|
|
- `constructoraId`: UUID (obligatorio, RLS)
|
|
- `status`: active | inactive | suspended | locked | all (default: active)
|
|
- `role`: Filtrar por rol
|
|
- `search`: Búsqueda por nombre o email (full-text search)
|
|
- `page`, `limit`: Paginación
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"users": [
|
|
{
|
|
"id": "uuid-123",
|
|
"fullName": "Juan Pérez",
|
|
"email": "juan.perez@empresa.com",
|
|
"role": "engineer",
|
|
"status": "active",
|
|
"avatarUrl": "https://...",
|
|
"lastLoginAt": "2025-11-20T10:30:00Z",
|
|
"createdAt": "2025-11-01T08:00:00Z"
|
|
}
|
|
],
|
|
"pagination": {
|
|
"total": 45,
|
|
"page": 1,
|
|
"limit": 20,
|
|
"totalPages": 3
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Actualizar Usuario
|
|
|
|
**Endpoint:** `PATCH /api/admin/users/{userId}`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"firstName": "Juan Carlos",
|
|
"phone": "+52 55 1234 5678",
|
|
"avatarUrl": "https://..."
|
|
}
|
|
```
|
|
|
|
**Campos editables:**
|
|
- Datos personales (firstName, lastName, phone, mobilePhone, avatarUrl)
|
|
- Status (solo admins)
|
|
- Rol (solo admins, con auditoría)
|
|
|
|
**Campos NO editables:**
|
|
- Email (es unique identifier)
|
|
- Password (endpoint separado)
|
|
- createdAt, createdBy
|
|
|
|
#### Cambiar Rol de Usuario
|
|
|
|
**Endpoint:** `PATCH /api/admin/users/{userId}/role`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"constructoraId": "uuid-A",
|
|
"newRole": "director"
|
|
}
|
|
```
|
|
|
|
**Validaciones:**
|
|
- Solo Director o super admin pueden cambiar roles
|
|
- Se registra en auditoría (acción crítica)
|
|
- Notificación al usuario del cambio
|
|
|
|
#### Cambiar Estado de Usuario
|
|
|
|
**Endpoint:** `PATCH /api/admin/users/{userId}/status`
|
|
|
|
**Request:**
|
|
```json
|
|
{
|
|
"status": "suspended",
|
|
"reason": "Violación de políticas de uso",
|
|
"suspendedUntil": "2025-12-01T00:00:00Z" // Opcional
|
|
}
|
|
```
|
|
|
|
**Estados permitidos:**
|
|
- `active` → `suspended`
|
|
- `active` → `inactive`
|
|
- `suspended` → `active`
|
|
- `inactive` → `active`
|
|
- `locked` → `active` (solo admin)
|
|
|
|
**Auditoría:**
|
|
- Se registra quién, cuándo y por qué cambió el estado
|
|
- Notificación al usuario suspendido/bloqueado
|
|
|
|
#### Eliminar Usuario (Soft Delete)
|
|
|
|
**Endpoint:** `DELETE /api/admin/users/{userId}`
|
|
|
|
**Comportamiento:**
|
|
- No eliminación física
|
|
- Set `deletedAt = now()`
|
|
- Status → `inactive`
|
|
- Se desactivan todas las asignaciones a constructoras
|
|
- Se cierran todas las sesiones activas
|
|
- Se mantiene en auditoría
|
|
|
|
**Reactivación:**
|
|
- `POST /api/admin/users/{userId}/restore`
|
|
|
|
---
|
|
|
|
## 🔐 Row Level Security (RLS)
|
|
|
|
### Políticas en PostgreSQL
|
|
|
|
Todas las tablas sensibles tienen políticas RLS que **automáticamente** filtran por `constructoraId`:
|
|
|
|
```sql
|
|
-- Ejemplo: Tabla de proyectos
|
|
CREATE POLICY projects_isolation_policy ON projects
|
|
USING (constructora_id = current_setting('app.current_constructora_id')::uuid);
|
|
|
|
-- Ejemplo: Tabla de usuarios (solo ven usuarios de su empresa)
|
|
CREATE POLICY users_isolation_policy ON user_constructoras
|
|
USING (constructora_id = current_setting('app.current_constructora_id')::uuid);
|
|
```
|
|
|
|
### Configuración por Sesión
|
|
|
|
Al hacer login o cambiar de constructora:
|
|
|
|
```sql
|
|
-- Backend ejecuta al inicio de cada request
|
|
SET app.current_constructora_id = 'uuid-A';
|
|
SET app.current_user_id = 'uuid-123';
|
|
SET app.current_user_role = 'engineer';
|
|
```
|
|
|
|
**Resultado:** Todas las queries se filtran automáticamente.
|
|
|
|
```sql
|
|
-- El developer escribe:
|
|
SELECT * FROM projects;
|
|
|
|
-- PostgreSQL ejecuta (automático):
|
|
SELECT * FROM projects
|
|
WHERE constructora_id = 'uuid-A';
|
|
```
|
|
|
|
**Ventaja:** Imposible acceder a datos de otra empresa por error de código.
|
|
|
|
---
|
|
|
|
## 📊 Casos de Uso
|
|
|
|
### Caso 1: Invitar Nuevo Ingeniero
|
|
|
|
**Actor:** Director General de Constructora ABC
|
|
|
|
**Flujo:**
|
|
1. Director va a "Administración" → "Usuarios" → "Invitar usuario"
|
|
2. Completa formulario:
|
|
- Email: `ing.carlos@empresa.com`
|
|
- Nombre: Carlos Martínez
|
|
- Rol: Ingeniero
|
|
- Constructora: Constructora ABC
|
|
3. Click en "Enviar invitación"
|
|
4. Sistema genera token único
|
|
5. Email enviado a `ing.carlos@empresa.com`
|
|
6. Carlos recibe email, click en link
|
|
7. Carlos completa registro (nombre, teléfono, contraseña)
|
|
8. Acepta términos
|
|
9. **Login automático** → Dashboard de Ingeniero
|
|
|
|
**Resultado:**
|
|
- Carlos es usuario activo
|
|
- Rol: `engineer` en Constructora ABC
|
|
- Acceso a módulos: Proyectos, Presupuestos, Control de Obra (CRUD)
|
|
|
|
### Caso 2: Usuario Multi-Empresa
|
|
|
|
**Actor:** Juan Pérez (Director en Empresa A, Ingeniero en Empresa B)
|
|
|
|
**Flujo:**
|
|
1. Juan hace login con `juan@email.com`
|
|
2. Sistema detecta que pertenece a 2 empresas
|
|
3. Muestra selector:
|
|
- 🏢 Constructora ABC (Director)
|
|
- 🏢 Constructora XYZ (Ingeniero)
|
|
4. Juan selecciona "Constructora ABC"
|
|
5. **Token JWT contiene:**
|
|
```json
|
|
{
|
|
"userId": "uuid-123",
|
|
"constructoraId": "uuid-A",
|
|
"role": "director"
|
|
}
|
|
```
|
|
6. Dashboard de Director (acceso completo)
|
|
7. Juan quiere revisar obra en Empresa B
|
|
8. Click en selector de empresa → "Constructora XYZ"
|
|
9. **Token se regenera:**
|
|
```json
|
|
{
|
|
"userId": "uuid-123",
|
|
"constructoraId": "uuid-B",
|
|
"role": "engineer"
|
|
}
|
|
```
|
|
10. Dashboard de Ingeniero (acceso limitado)
|
|
|
|
**Resultado:**
|
|
- Sin re-login
|
|
- Datos completamente aislados entre empresas
|
|
- Permisos cambian según rol en cada empresa
|
|
|
|
### Caso 3: Suspensión Temporal
|
|
|
|
**Actor:** Director suspende a Residente
|
|
|
|
**Flujo:**
|
|
1. Director va a "Usuarios" → Busca "Pedro González (Residente)"
|
|
2. Click en "Acciones" → "Suspender usuario"
|
|
3. Modal de confirmación:
|
|
- Razón: "Investigación por incidente en obra"
|
|
- Suspender hasta: 2025-12-01
|
|
4. Click en "Confirmar"
|
|
5. Sistema:
|
|
- Cambia status de Pedro a `suspended`
|
|
- Cierra todas las sesiones activas
|
|
- Envía email a Pedro notificando suspensión
|
|
- Registra en auditoría (quién, cuándo, por qué)
|
|
6. Pedro intenta login → Error: "Cuenta suspendida temporalmente. Contacte al administrador."
|
|
7. 2025-12-01 00:00:00 → Cron job reactiva automáticamente
|
|
8. Pedro recibe email: "Tu cuenta ha sido reactivada"
|
|
|
|
**Resultado:**
|
|
- Suspensión temporal efectiva
|
|
- Trazabilidad completa
|
|
- Reactivación automática
|
|
|
|
---
|
|
|
|
## ✅ Criterios de Aceptación
|
|
|
|
### AC1: Invitación de Usuarios
|
|
|
|
**DADO** un Director de Constructora ABC
|
|
**CUANDO** invita a un nuevo usuario con email `nuevo@empresa.com` y rol `engineer`
|
|
**ENTONCES**
|
|
- ✅ Se genera token de invitación único
|
|
- ✅ Token válido por 7 días
|
|
- ✅ Email enviado con link de registro
|
|
- ✅ Usuario completa registro en < 5 minutos
|
|
- ✅ Primer login automático exitoso
|
|
- ✅ Usuario activo con rol `engineer`
|
|
|
|
### AC2: Multi-Tenancy Estricto
|
|
|
|
**DADO** dos usuarios de diferentes empresas (A y B)
|
|
**CUANDO** ambos hacen login simultáneamente
|
|
**ENTONCES**
|
|
- ✅ Usuario A solo ve datos de Empresa A
|
|
- ✅ Usuario B solo ve datos de Empresa B
|
|
- ✅ No es posible acceder a datos de otra empresa (bloqueado por RLS)
|
|
- ✅ Tests de penetración validan aislamiento
|
|
|
|
### AC3: Cambio de Empresa sin Re-Login
|
|
|
|
**DADO** un usuario que pertenece a 2 empresas
|
|
**CUANDO** cambia de empresa desde el selector
|
|
**ENTONCES**
|
|
- ✅ Token JWT se regenera con nuevo `constructoraId` y `role`
|
|
- ✅ Dashboard se actualiza mostrando datos de nueva empresa
|
|
- ✅ Permisos cambian según rol en nueva empresa
|
|
- ✅ No se requiere re-login
|
|
- ✅ Operación < 500ms
|
|
|
|
### AC4: Gestión de Estados de Cuenta
|
|
|
|
**DADO** un administrador
|
|
**CUANDO** suspende a un usuario
|
|
**ENTONCES**
|
|
- ✅ Estado cambia a `suspended`
|
|
- ✅ Todas las sesiones activas se cierran
|
|
- ✅ Usuario no puede hacer login
|
|
- ✅ Email de notificación enviado
|
|
- ✅ Auditoría registra la acción con razón
|
|
- ✅ Reactivación manual o automática funciona
|
|
|
|
### AC5: Bloqueo por Intentos Fallidos
|
|
|
|
**DADO** un usuario con contraseña incorrecta
|
|
**CUANDO** falla 5 veces consecutivas
|
|
**ENTONCES**
|
|
- ✅ Cuenta bloqueada automáticamente (`locked`)
|
|
- ✅ `lockedUntil` = now() + 30 minutos
|
|
- ✅ Mensaje: "Cuenta bloqueada. Intente en 30 minutos o contacte al administrador."
|
|
- ✅ Email de notificación al usuario
|
|
- ✅ Auditoría registra evento de seguridad
|
|
- ✅ Tras 30 min, desbloqueo automático
|
|
|
|
---
|
|
|
|
## 🧪 Escenarios de Prueba
|
|
|
|
### Test 1: Invitación Exitosa
|
|
|
|
```typescript
|
|
describe('RF-ADM-001: Invitación de usuarios', () => {
|
|
it('debe crear invitación y enviar email', async () => {
|
|
const director = await loginAs('director');
|
|
|
|
const response = await api.post('/admin/users/invite', {
|
|
email: 'test@empresa.com',
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
constructoraId: director.constructoraId,
|
|
role: 'engineer'
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.data.invitationId).toBeDefined();
|
|
expect(response.data.token).toBeDefined();
|
|
expect(response.data.emailSent).toBe(true);
|
|
|
|
// Validar que email fue enviado
|
|
const emails = await getEmailsSent();
|
|
expect(emails).toContainEqual(
|
|
expect.objectContaining({
|
|
to: 'test@empresa.com',
|
|
subject: expect.stringContaining('Invitación')
|
|
})
|
|
);
|
|
});
|
|
|
|
it('debe permitir registro con token válido', async () => {
|
|
const { token } = await createInvitation('nuevo@test.com', 'engineer');
|
|
|
|
const response = await api.post('/auth/register-by-invitation', {
|
|
token,
|
|
firstName: 'Nuevo',
|
|
lastName: 'Usuario',
|
|
password: 'SecurePass123!',
|
|
passwordConfirm: 'SecurePass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.data.user.status).toBe('active');
|
|
expect(response.data.user.role).toBe('engineer');
|
|
expect(response.data.accessToken).toBeDefined();
|
|
});
|
|
|
|
it('debe rechazar token expirado', async () => {
|
|
const expiredToken = await createExpiredInvitation();
|
|
|
|
const response = await api.post('/auth/register-by-invitation', {
|
|
token: expiredToken,
|
|
firstName: 'Test',
|
|
lastName: 'User',
|
|
password: 'SecurePass123!'
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.data.error).toBe('Invitation expired');
|
|
});
|
|
});
|
|
```
|
|
|
|
### Test 2: Multi-Tenancy Aislamiento
|
|
|
|
```typescript
|
|
describe('RF-ADM-001: Multi-tenancy isolation', () => {
|
|
it('debe aislar datos entre empresas', async () => {
|
|
// Usuario A en Empresa A
|
|
const userA = await loginAs('engineer', 'empresa-a');
|
|
|
|
// Usuario B en Empresa B
|
|
const userB = await loginAs('engineer', 'empresa-b');
|
|
|
|
// Crear proyecto en Empresa A
|
|
const projectA = await api.post('/projects', {
|
|
name: 'Proyecto A',
|
|
constructoraId: userA.constructoraId
|
|
}, { headers: { Authorization: userA.token } });
|
|
|
|
// Usuario B NO debe ver proyecto de A
|
|
const projectsB = await api.get('/projects', {
|
|
headers: { Authorization: userB.token }
|
|
});
|
|
|
|
expect(projectsB.data.projects).not.toContainEqual(
|
|
expect.objectContaining({ id: projectA.data.id })
|
|
);
|
|
|
|
// Intentar acceso directo (debe fallar)
|
|
const directAccess = await api.get(`/projects/${projectA.data.id}`, {
|
|
headers: { Authorization: userB.token }
|
|
});
|
|
|
|
expect(directAccess.status).toBe(404); // RLS lo bloquea
|
|
});
|
|
});
|
|
```
|
|
|
|
### Test 3: Cambio de Rol (Auditoría)
|
|
|
|
```typescript
|
|
describe('RF-ADM-001: Cambio de rol', () => {
|
|
it('debe auditar cambio de rol', async () => {
|
|
const director = await loginAs('director');
|
|
const engineer = await createUser('engineer');
|
|
|
|
// Cambiar rol de engineer a director
|
|
const response = await api.patch(
|
|
`/admin/users/${engineer.id}/role`,
|
|
{ newRole: 'director' },
|
|
{ headers: { Authorization: director.token } }
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
// Validar auditoría
|
|
const auditLogs = await api.get('/admin/audit-logs', {
|
|
params: { entityId: engineer.id, action: 'role_change' },
|
|
headers: { Authorization: director.token }
|
|
});
|
|
|
|
expect(auditLogs.data.logs).toContainEqual(
|
|
expect.objectContaining({
|
|
userId: director.id,
|
|
action: 'role_change',
|
|
changes: expect.arrayContaining([
|
|
{ field: 'role', oldValue: 'engineer', newValue: 'director' }
|
|
])
|
|
})
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🔗 Referencias
|
|
|
|
- **Especificación técnica:** [ET-ADM-001](../especificaciones/ET-ADM-001-rbac-multi-tenancy.md)
|
|
- **Historia de usuario:** [US-ADM-001](../historias-usuario/US-ADM-001-crear-usuarios.md), [US-ADM-002](../historias-usuario/US-ADM-002-asignar-roles-permisos.md)
|
|
- **Módulo base:** [README.md](../README.md)
|
|
- **Análisis GAMILIT:** [ANALISIS-REUTILIZACION-GAMILIT.md](../../ANALISIS-REUTILIZACION-GAMILIT.md)
|
|
|
|
---
|
|
|
|
**Generado:** 2025-11-20
|
|
**Versión:** 1.0
|
|
**Autor:** Sistema de Documentación Técnica
|
|
**Estado:** ✅ Completo
|