erp-core/docs/06-test-plans/TP-rbac.md

20 KiB

Test Plan: MGN-003 Roles/RBAC

Identificacion

Campo Valor
ID TP-MGN003
Modulo MGN-003 Roles/RBAC
Version 1.0
Fecha 2025-12-05
Autor System
Estado Ready

Alcance

Este plan de pruebas cubre todos los flujos del sistema de control de acceso basado en roles:

RF Nombre Prioridad
RF-ROLE-001 CRUD de Roles P0
RF-ROLE-002 Gestion de Permisos P0
RF-ROLE-003 Asignacion Roles-Usuarios P0
RF-ROLE-004 Guards y Middlewares RBAC P0

Estrategia de Pruebas

Niveles de Testing

┌─────────────────────────────────────────────────────────────────┐
│                        E2E Tests (10%)                          │
│   Cypress - Flujos completos de gestion de roles                │
├─────────────────────────────────────────────────────────────────┤
│                   Integration Tests (30%)                        │
│   Supertest - API endpoints, guards, validacion permisos        │
├─────────────────────────────────────────────────────────────────┤
│                     Unit Tests (60%)                             │
│   Jest - Services, guards, decorators, cache                    │
└─────────────────────────────────────────────────────────────────┘

Cobertura Objetivo

Tipo Coverage Target
Unit Tests > 85%
Integration Tests > 75%
E2E Tests Flujos criticos

Test Cases

TC-RBAC-001: Crear rol exitosamente

Campo Valor
RF RF-ROLE-001
Tipo Integration
Prioridad P0

Precondiciones:

  • Admin autenticado con permiso roles:create
  • Permisos existentes en el sistema

Datos de entrada:

{
  "name": "Vendedor",
  "description": "Equipo de ventas",
  "permissionIds": ["perm-uuid-1", "perm-uuid-2"]
}

Pasos:

  1. POST /api/v1/roles con datos validos
  2. Verificar response status 201
  3. Verificar estructura del rol retornado
  4. Verificar slug generado
  5. Verificar isBuiltIn = false

Implementacion Jest:

describe('RolesController - Create', () => {
  it('should create role with permissions', async () => {
    const createDto = {
      name: 'Vendedor',
      description: 'Equipo de ventas',
      permissionIds: [testPermission1.id, testPermission2.id],
    };

    const response = await request(app.getHttpServer())
      .post('/api/v1/roles')
      .set('Authorization', `Bearer ${adminToken}`)
      .send(createDto)
      .expect(201);

    expect(response.body).toMatchObject({
      name: 'Vendedor',
      slug: 'vendedor',
      description: 'Equipo de ventas',
      isBuiltIn: false,
    });
    expect(response.body.permissions).toHaveLength(2);
  });
});

TC-RBAC-002: No crear rol con nombre duplicado

Campo Valor
RF RF-ROLE-001
Tipo Integration
Prioridad P0

Implementacion Jest:

it('should not create role with duplicate name', async () => {
  // Crear primer rol
  await rolesService.create(tenantId, {
    name: 'Vendedor',
    permissionIds: [testPermission.id],
  }, adminUser.id);

  // Intentar crear duplicado
  const response = await request(app.getHttpServer())
    .post('/api/v1/roles')
    .set('Authorization', `Bearer ${adminToken}`)
    .send({ name: 'Vendedor', permissionIds: [testPermission.id] })
    .expect(409);

  expect(response.body.message).toBe('Ya existe un rol con este nombre');
});

TC-RBAC-003: No eliminar rol built-in

Campo Valor
RF RF-ROLE-001
Tipo Integration
Prioridad P0

Implementacion Jest:

it('should not delete built-in role', async () => {
  const adminRole = await roleRepository.findOne({
    where: { slug: 'admin', tenantId },
  });

  const response = await request(app.getHttpServer())
    .delete(`/api/v1/roles/${adminRole.id}`)
    .set('Authorization', `Bearer ${superAdminToken}`)
    .expect(400);

  expect(response.body.message).toBe('No se pueden eliminar roles del sistema');
});

TC-RBAC-004: Listar permisos agrupados

Campo Valor
RF RF-ROLE-002
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('PermissionsController - List', () => {
  it('should return permissions grouped by module', async () => {
    const response = await request(app.getHttpServer())
      .get('/api/v1/permissions')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);

    expect(response.body.data).toBeInstanceOf(Array);
    expect(response.body.data[0]).toHaveProperty('module');
    expect(response.body.data[0]).toHaveProperty('moduleName');
    expect(response.body.data[0]).toHaveProperty('permissions');
    expect(response.body.data[0].permissions[0]).toHaveProperty('code');
    expect(response.body.data[0].permissions[0]).toHaveProperty('name');
  });

  it('should filter permissions by search', async () => {
    const response = await request(app.getHttpServer())
      .get('/api/v1/permissions?search=users')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);

    const allCodes = response.body.data.flatMap(g => g.permissions.map(p => p.code));
    expect(allCodes.every(c => c.includes('users'))).toBe(true);
  });
});

TC-RBAC-005: Asignar roles a usuario

Campo Valor
RF RF-ROLE-003
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('User Roles Assignment', () => {
  it('should assign multiple roles to user', async () => {
    const vendedorRole = await createRole('Vendedor');
    const contadorRole = await createRole('Contador');

    const response = await request(app.getHttpServer())
      .put(`/api/v1/users/${testUser.id}/roles`)
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ roleIds: [vendedorRole.id, contadorRole.id] })
      .expect(200);

    expect(response.body.roles).toHaveLength(2);
    expect(response.body.roles.map(r => r.name)).toContain('Vendedor');
    expect(response.body.roles.map(r => r.name)).toContain('Contador');
  });

  it('should calculate effective permissions from multiple roles', async () => {
    const response = await request(app.getHttpServer())
      .get(`/api/v1/users/${testUser.id}/permissions`)
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);

    expect(response.body.roles).toContain('vendedor');
    expect(response.body.all).toBeInstanceOf(Array);
    expect(response.body.all.length).toBeGreaterThan(0);
  });
});

TC-RBAC-006: Proteccion de rol super_admin

Campo Valor
RF RF-ROLE-003
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Super Admin Protection', () => {
  it('should not allow admin to assign super_admin role', async () => {
    const superAdminRole = await roleRepository.findOne({
      where: { slug: 'super_admin', tenantId },
    });

    const response = await request(app.getHttpServer())
      .put(`/api/v1/users/${testUser.id}/roles`)
      .set('Authorization', `Bearer ${adminToken}`) // admin, no super_admin
      .send({ roleIds: [superAdminRole.id] })
      .expect(403);

    expect(response.body.message).toBe('Solo Super Admin puede asignar este rol');
  });

  it('should not allow removing last super_admin', async () => {
    // Solo hay un super_admin en el tenant
    const response = await request(app.getHttpServer())
      .put(`/api/v1/users/${superAdminUser.id}/roles`)
      .set('Authorization', `Bearer ${superAdminToken}`)
      .send({ roleIds: [] }) // Quitar todos los roles
      .expect(400);

    expect(response.body.message).toBe('Debe existir al menos un Super Admin');
  });
});

TC-RBAC-007: RbacGuard permite acceso con permiso

Campo Valor
RF RF-ROLE-004
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('RbacGuard', () => {
  it('should allow access with required permission', async () => {
    // Usuario con permiso users:read
    await assignPermissionToUser(testUser.id, 'users:read');

    await request(app.getHttpServer())
      .get('/api/v1/users')
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(200);
  });

  it('should deny access without required permission', async () => {
    // Usuario sin permiso users:delete
    const response = await request(app.getHttpServer())
      .delete('/api/v1/users/some-id')
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(403);

    expect(response.body.message).toBe('No tienes permiso para realizar esta accion');
    // No debe revelar que permiso falta
    expect(response.body.requiredPermission).toBeUndefined();
  });
});

TC-RBAC-008: Wildcard permite acceso

Campo Valor
RF RF-ROLE-004
Tipo Unit
Prioridad P0

Implementacion Jest:

describe('Wildcard Permissions', () => {
  it('should expand wildcard to include all module permissions', async () => {
    // Asignar users:* al usuario
    await assignPermissionToUser(testUser.id, 'users:*');

    const effective = await permissionsService.getEffectivePermissions(testUser.id);

    expect(effective.direct).toContain('users:*');
    expect(effective.all).toContain('users:read');
    expect(effective.all).toContain('users:create');
    expect(effective.all).toContain('users:delete');
  });

  it('should allow access via wildcard', async () => {
    await assignPermissionToUser(testUser.id, 'users:*');

    // Endpoint requiere users:delete
    await request(app.getHttpServer())
      .delete(`/api/v1/users/${anotherUser.id}`)
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(200);
  });
});

TC-RBAC-009: Super Admin bypass

Campo Valor
RF RF-ROLE-004
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Super Admin Bypass', () => {
  it('should bypass permission check for super_admin', async () => {
    // Endpoint que requiere permiso especial
    await request(app.getHttpServer())
      .delete('/api/v1/tenants/config/dangerous')
      .set('Authorization', `Bearer ${superAdminToken}`)
      .expect(200);
  });

  it('should not bypass for regular admin', async () => {
    await request(app.getHttpServer())
      .delete('/api/v1/tenants/config/dangerous')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(403);
  });
});

TC-RBAC-010: Owner puede acceder

Campo Valor
RF RF-ROLE-004
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Owner Access', () => {
  it('should allow owner to update own resource', async () => {
    // Usuario sin permiso users:update
    // pero es owner del recurso (su propio perfil)
    await request(app.getHttpServer())
      .patch(`/api/v1/users/${testUser.id}`)
      .set('Authorization', `Bearer ${testUserToken}`)
      .send({ firstName: 'Nuevo Nombre' })
      .expect(200);
  });

  it('should deny non-owner without permission', async () => {
    // Usuario sin permiso intentando modificar otro usuario
    await request(app.getHttpServer())
      .patch(`/api/v1/users/${anotherUser.id}`)
      .set('Authorization', `Bearer ${testUserToken}`)
      .send({ firstName: 'Hack' })
      .expect(403);
  });
});

TC-RBAC-011: Cache de permisos

Campo Valor
RF RF-ROLE-004
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Permissions Cache', () => {
  it('should cache permissions', async () => {
    const spy = jest.spyOn(permissionsService, 'calculateEffective');

    // Primera llamada - calcula
    await permissionsService.getEffectivePermissions(testUser.id);
    expect(spy).toHaveBeenCalledTimes(1);

    // Segunda llamada - usa cache
    await permissionsService.getEffectivePermissions(testUser.id);
    expect(spy).toHaveBeenCalledTimes(1); // No incrementa
  });

  it('should invalidate cache on role change', async () => {
    // Obtener permisos (cachea)
    const before = await permissionsService.getEffectivePermissions(testUser.id);

    // Cambiar roles del usuario
    await rolesService.assignRoles(testUser.id, [newRole.id], adminUser.id);

    // Cache invalidado, recalcula
    const after = await permissionsService.getEffectivePermissions(testUser.id);

    expect(after.roles).not.toEqual(before.roles);
  });
});

TC-RBAC-012: AnyPermission (OR)

Campo Valor
RF RF-ROLE-004
Tipo Integration
Prioridad P1

Implementacion Jest:

describe('AnyPermission Decorator', () => {
  // Endpoint decorado con @AnyPermission('users:update', 'users:admin')
  it('should allow access with first permission', async () => {
    await assignPermissionToUser(testUser.id, 'users:update');

    await request(app.getHttpServer())
      .patch('/api/v1/users/special-action')
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(200);
  });

  it('should allow access with second permission', async () => {
    await assignPermissionToUser(testUser.id, 'users:admin');

    await request(app.getHttpServer())
      .patch('/api/v1/users/special-action')
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(200);
  });

  it('should deny without any of the permissions', async () => {
    // Usuario solo con users:read
    await assignPermissionToUser(testUser.id, 'users:read');

    await request(app.getHttpServer())
      .patch('/api/v1/users/special-action')
      .set('Authorization', `Bearer ${testUserToken}`)
      .expect(403);
  });
});

TC-RBAC-013: Multi-tenant isolation

Campo Valor
RF Transversal
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Multi-tenant Isolation', () => {
  let tenantA, tenantB;
  let roleA, roleB;

  beforeAll(async () => {
    tenantA = await createTenant('Tenant A');
    tenantB = await createTenant('Tenant B');

    roleA = await createRole('Custom Role', tenantA.id);
    roleB = await createRole('Custom Role', tenantB.id);
  });

  it('should only list roles from own tenant', async () => {
    const response = await request(app.getHttpServer())
      .get('/api/v1/roles')
      .set('Authorization', `Bearer ${tenantAAdminToken}`)
      .expect(200);

    const roleIds = response.body.data.map(r => r.id);
    expect(roleIds).toContain(roleA.id);
    expect(roleIds).not.toContain(roleB.id);
  });

  it('should not access role from another tenant', async () => {
    await request(app.getHttpServer())
      .get(`/api/v1/roles/${roleB.id}`)
      .set('Authorization', `Bearer ${tenantAAdminToken}`)
      .expect(404);
  });
});

TC-RBAC-014: Soft delete con reasignacion

Campo Valor
RF RF-ROLE-001
Tipo Integration
Prioridad P0

Implementacion Jest:

describe('Role Soft Delete', () => {
  it('should soft delete role and reassign users', async () => {
    const customRole = await createRole('ToDelete');
    const userRole = await roleRepository.findOne({ where: { slug: 'user' } });

    // Asignar rol a usuarios
    await assignRoleToUsers(customRole.id, [user1.id, user2.id, user3.id]);

    // Eliminar con reasignacion
    await request(app.getHttpServer())
      .delete(`/api/v1/roles/${customRole.id}?reassignTo=${userRole.id}`)
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200);

    // Verificar soft delete
    const deleted = await roleRepository.findOne({
      where: { id: customRole.id },
      withDeleted: true,
    });
    expect(deleted.deletedAt).not.toBeNull();

    // Verificar reasignacion
    const user1Roles = await userRoleRepository.find({ where: { userId: user1.id } });
    expect(user1Roles.some(ur => ur.roleId === userRole.id)).toBe(true);
    expect(user1Roles.some(ur => ur.roleId === customRole.id)).toBe(false);
  });
});

Datos de Prueba

Permisos de Prueba

const testPermissions = [
  { code: 'users:read', name: 'Leer usuarios', module: 'users' },
  { code: 'users:create', name: 'Crear usuarios', module: 'users' },
  { code: 'users:update', name: 'Actualizar usuarios', module: 'users' },
  { code: 'users:delete', name: 'Eliminar usuarios', module: 'users' },
  { code: 'users:*', name: 'Todos usuarios', module: 'users' },
  { code: 'roles:read', name: 'Leer roles', module: 'roles' },
  { code: 'roles:create', name: 'Crear roles', module: 'roles' },
];

Usuarios de Prueba

const testUsers = {
  superAdmin: {
    email: 'super@test.com',
    roles: ['super_admin'],
  },
  admin: {
    email: 'admin@test.com',
    roles: ['admin'],
  },
  regular: {
    email: 'user@test.com',
    roles: ['user'],
  },
};

Setup y Teardown

beforeAll(async () => {
  // Crear tenant de prueba
  testTenant = await createTestTenant();

  // Seed permisos
  await seedPermissions();

  // Crear roles built-in
  await seedBuiltInRoles(testTenant.id);

  // Crear usuarios de prueba
  superAdminUser = await createUser('super_admin');
  adminUser = await createUser('admin');
  testUser = await createUser('user');

  // Generar tokens
  superAdminToken = await generateToken(superAdminUser);
  adminToken = await generateToken(adminUser);
  testUserToken = await generateToken(testUser);
});

afterAll(async () => {
  await cleanDatabase();
  await closeConnections();
});

beforeEach(async () => {
  // Limpiar roles custom y asignaciones entre tests
  await truncateTables([
    'core_rbac.user_roles',
    'core_rbac.role_permissions',
  ]);
  await roleRepository.delete({ isBuiltIn: false });

  // Limpiar cache
  await cacheManager.reset();
});

Matriz de Trazabilidad

Test Case RF User Story Criterio
TC-RBAC-001 RF-ROLE-001 US-MGN003-001 Escenario 1
TC-RBAC-002 RF-ROLE-001 US-MGN003-001 Escenario 3
TC-RBAC-003 RF-ROLE-001 US-MGN003-001 Escenario 4
TC-RBAC-004 RF-ROLE-002 US-MGN003-002 Escenario 1
TC-RBAC-005 RF-ROLE-003 US-MGN003-003 Escenario 1
TC-RBAC-006 RF-ROLE-003 US-MGN003-003 Escenarios 4-5
TC-RBAC-007 RF-ROLE-004 US-MGN003-004 Escenarios 1-2
TC-RBAC-008 RF-ROLE-004 US-MGN003-004 Escenario 3
TC-RBAC-009 RF-ROLE-004 US-MGN003-004 Escenario 4
TC-RBAC-010 RF-ROLE-004 US-MGN003-004 Escenario 6
TC-RBAC-011 RF-ROLE-004 US-MGN003-004 Escenario 7
TC-RBAC-012 RF-ROLE-004 US-MGN003-004 Escenario 5
TC-RBAC-013 Transversal - Multi-tenant
TC-RBAC-014 RF-ROLE-001 US-MGN003-001 Escenario 5

Metricas de Calidad

Metrica Objetivo
Tests pasando 100%
Code coverage > 85%
Bugs criticos 0
Bugs mayores < 2

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 System Creacion inicial con 14 test cases