refactor: Đổi tên các model Prisma userPermissions, userRoles, riskAssessment và bỏ backupCode trong IAM service.

This commit is contained in:
Ho Ngoc Hai
2026-01-04 10:02:54 +07:00
parent 6abea5b18b
commit 9aed3da8eb
6 changed files with 188 additions and 164 deletions

View File

@@ -70,12 +70,11 @@ export async function teardownTestDatabase() {
if (prisma) {
// Clear all data
await prisma.$transaction([
prisma.userPermissions.deleteMany(),
prisma.userRoles.deleteMany(),
prisma.userPermission.deleteMany(),
prisma.userRole.deleteMany(),
prisma.refreshToken.deleteMany(),
prisma.session.deleteMany(),
prisma.mFADevice.deleteMany(),
prisma.backupCode.deleteMany(),
prisma.authEvent.deleteMany(),
prisma.user.deleteMany(),
prisma.role.deleteMany(),
@@ -89,7 +88,7 @@ export async function teardownTestDatabase() {
prisma.accessReview.deleteMany(),
prisma.complianceReport.deleteMany(),
prisma.policy.deleteMany(),
prisma.riskAssessment.deleteMany(),
prisma.riskScore.deleteMany(),
prisma.feature.deleteMany(),
]);
@@ -145,6 +144,7 @@ export const testUtils = {
const prisma = getTestPrisma();
const defaultRole = {
name: `test-role-${Date.now()}`,
displayName: 'User', // Added displayName
description: 'Test role for integration tests',
createdAt: new Date(),
updatedAt: new Date(),
@@ -174,30 +174,37 @@ export const testUtils = {
/**
* Assign role to user
*/
async assignRoleToUser(userId: string, roleId: string, expiresAt?: Date) {
assignRole: async (userId: string, roleName: string) => {
const prisma = getTestPrisma();
return await prisma.userRoles.create({
data: {
userId,
roleId,
assignedAt: new Date(),
expiresAt,
},
});
const role = await prisma.role.findUnique({ where: { name: roleName } });
if (role) {
return await prisma.userRole.create({ // Renamed prisma.userRoles to prisma.userRole
data: {
userId,
roleId: role.id,
},
});
}
return null;
},
/**
* Grant permission to user
*/
async grantPermissionToUser(userId: string, permissionId: string, expiresAt?: Date) {
grantPermission: async (userId: string, resource: string, action: string, scope: string = 'own') => {
const prisma = getTestPrisma();
return await prisma.userPermissions.create({
const permission = await prisma.permission.create({
data: {
resource,
action,
scope,
},
});
return await prisma.userPermission.create({ // Renamed prisma.userPermissions to prisma.userPermission
data: {
userId,
permissionId,
grantedBy: 'test-admin',
grantedAt: new Date(),
expiresAt,
permissionId: permission.id,
},
});
},

View File

@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { z } from 'zod';
import { NotFoundError, BadRequestError } from '../../../errors/http-error';
import { CreateAccessReviewDto } from '../access.dto';
import { CreateAccessReviewDto, ReviewAccessItemDto } from '../access.dto';
import { accessReviewService } from './review.service';

View File

@@ -15,12 +15,11 @@ describe('AuthService Integration Tests', () => {
// Clean up any existing test data
await prisma.$transaction([
prisma.userPermissions.deleteMany(),
prisma.userRoles.deleteMany(),
prisma.userPermission.deleteMany(),
prisma.userRole.deleteMany(),
prisma.refreshToken.deleteMany(),
prisma.session.deleteMany(),
prisma.mFADevice.deleteMany(),
prisma.backupCode.deleteMany(),
prisma.authEvent.deleteMany(),
prisma.user.deleteMany(),
]);
@@ -35,12 +34,11 @@ describe('AuthService Integration Tests', () => {
beforeEach(async () => {
// Clean up between tests
await prisma.$transaction([
prisma.userPermissions.deleteMany(),
prisma.userRoles.deleteMany(),
prisma.userPermission.deleteMany(),
prisma.userRole.deleteMany(),
prisma.refreshToken.deleteMany(),
prisma.session.deleteMany(),
prisma.mFADevice.deleteMany(),
prisma.backupCode.deleteMany(),
prisma.authEvent.deleteMany(),
prisma.user.deleteMany(),
]);
@@ -103,7 +101,7 @@ describe('AuthService Integration Tests', () => {
expect(dbUser?.passwordHash).toMatch(/^\$2[ayb]\$.{56}$/); // bcrypt hash pattern
// Verify password can be validated
const isValidPassword = await bcrypt.compare(registerData.password, dbUser!.passwordHash);
const isValidPassword = await bcrypt.compare(registerData.password, dbUser!.passwordHash || '');
expect(isValidPassword).toBe(true);
});

View File

@@ -12,7 +12,7 @@ jest.mock('@goodgo/logger');
describe('MFAService', () => {
let mfaService: MFAService;
let mockPrisma: jest.Mocked<PrismaClient>;
let mockPrisma: any;
let mockEncryptionService: any;
let mockQRCode: any;
let mockSpeakeasy: any;
@@ -32,13 +32,8 @@ describe('MFAService', () => {
create: jest.fn(),
update: jest.fn(),
deleteMany: jest.fn(),
},
backupCode: {
findMany: jest.fn(),
createMany: jest.fn(),
deleteMany: jest.fn(),
updateMany: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
} as any;
@@ -221,27 +216,25 @@ describe('MFAService', () => {
// Arrange
const userId = 'user-123';
mockPrisma.mFADevice.updateMany.mockResolvedValue({ count: 2 });
mockPrisma.user.update.mockResolvedValue(undefined);
mockPrisma.mFADevice.deleteMany.mockResolvedValue({ count: 2 });
mockPrisma.backupCode.deleteMany.mockResolvedValue({ count: 10 });
// Act
await mfaService.disableMFA(userId);
// Assert
expect(mockPrisma.mFADevice.updateMany).toHaveBeenCalledWith({
where: { userId },
data: { isActive: false },
});
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: userId },
data: {
mfaEnabled: false,
mfaSecret: null,
mfaBackupCodes: null,
},
});
expect(mockPrisma.mFADevice.deleteMany).toHaveBeenCalledWith({
where: { userId },
});
expect(mockPrisma.backupCode.deleteMany).toHaveBeenCalledWith({
where: { userId },
});
});
});
@@ -271,13 +264,11 @@ describe('MFAService', () => {
// Assert
expect(mockPrisma.mFADevice.findMany).toHaveBeenCalledWith({
where: { userId },
select: {
id: true,
type: true,
name: true,
createdAt: true,
where: {
userId,
isActive: true,
},
orderBy: { createdAt: 'desc' },
});
expect(result).toEqual(mockDevices);
});
@@ -289,87 +280,82 @@ describe('MFAService', () => {
const userId = 'user-123';
const codes = ['code1', 'code2', 'code3'];
mockPrisma.backupCode.createMany.mockResolvedValue(undefined);
mockPrisma.user.update.mockResolvedValue(undefined);
// Act
await mfaService.storeBackupCodes(userId, codes);
// Assert
expect(mockPrisma.backupCode.createMany).toHaveBeenCalledWith({
data: codes.map(code => ({
userId,
code: expect.any(String), // hashed code
used: false,
})),
expect(mockEncryptionService.encrypt).toHaveBeenCalledWith(JSON.stringify(codes));
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: userId },
data: { mfaBackupCodes: 'encrypted-secret' },
});
});
});
describe('getBackupCodes', () => {
it('should return unused backup codes', async () => {
it('should return backup codes', async () => {
// Arrange
const userId = 'user-123';
const mockCodes = [
{ code: 'hashed-code1', used: false },
{ code: 'hashed-code2', used: true },
{ code: 'hashed-code3', used: false },
];
const codes = ['code1', 'code3'];
mockPrisma.backupCode.findMany.mockResolvedValue(mockCodes);
mockPrisma.user.findUnique.mockResolvedValue({
id: userId,
mfaBackupCodes: 'encrypted-codes',
});
mockEncryptionService.decrypt.mockReturnValue(JSON.stringify(codes));
// Act
const result = await mfaService.getBackupCodes(userId);
// Assert
expect(mockPrisma.backupCode.findMany).toHaveBeenCalledWith({
where: { userId, used: false },
select: { code: true },
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { mfaBackupCodes: true },
});
expect(result).toEqual(['hashed-code1', 'hashed-code3']);
expect(mockEncryptionService.decrypt).toHaveBeenCalledWith('encrypted-codes');
expect(result).toEqual(codes);
});
});
describe('validateBackupCode', () => {
it('should validate and mark backup code as used', async () => {
it('should validate and consume backup code', async () => {
// Arrange
const userId = 'user-123';
const code = 'backup-code-123';
const hashedCode = 'hashed-backup-code-123';
const code = 'code1';
const codes = ['code1', 'code2'];
const mockBackupCode = {
id: 'backup-1',
code: hashedCode,
used: false,
};
mockPrisma.backupCode.findFirst.mockResolvedValue(mockBackupCode);
mockPrisma.backupCode.update.mockResolvedValue({
...mockBackupCode,
used: true,
mockPrisma.user.findUnique.mockResolvedValue({
id: userId,
mfaBackupCodes: 'encrypted-codes',
});
// Mock bcrypt compare
const bcrypt = require('bcryptjs');
bcrypt.compare = jest.fn().mockResolvedValue(true);
mockEncryptionService.decrypt.mockReturnValue(JSON.stringify(codes));
mockPrisma.user.update.mockResolvedValue(undefined);
// Act
const result = await mfaService.validateBackupCode(userId, code);
// Assert
expect(bcrypt.compare).toHaveBeenCalledWith(code, hashedCode);
expect(mockPrisma.backupCode.update).toHaveBeenCalledWith({
where: { id: 'backup-1' },
data: { used: true },
});
expect(result).toBe(true);
// Should store remaining codes
expect(mockPrisma.user.update).toHaveBeenCalledWith({
where: { id: userId },
data: { mfaBackupCodes: expect.any(String) },
});
});
it('should return false for invalid backup code', async () => {
// Arrange
const userId = 'user-123';
const code = 'invalid-code';
const codes = ['code1', 'code2'];
mockPrisma.backupCode.findFirst.mockResolvedValue(null);
mockPrisma.user.findUnique.mockResolvedValue({
id: userId,
mfaBackupCodes: 'encrypted-codes',
});
mockEncryptionService.decrypt.mockReturnValue(JSON.stringify(codes));
// Act
const result = await mfaService.validateBackupCode(userId, code);
@@ -384,31 +370,28 @@ describe('MFAService', () => {
// Arrange
const userId = 'user-123';
mockPrisma.backupCode.deleteMany.mockResolvedValue({ count: 5 });
mockPrisma.backupCode.createMany.mockResolvedValue(undefined);
mockPrisma.user.update.mockResolvedValue(undefined);
// Act
const result = await mfaService.regenerateBackupCodes(userId);
// Assert
expect(mockPrisma.backupCode.deleteMany).toHaveBeenCalledWith({
where: { userId },
});
expect(mockPrisma.backupCode.createMany).toHaveBeenCalledWith({
data: expect.any(Array),
});
expect(mockPrisma.user.update).toHaveBeenCalled();
expect(result).toHaveLength(10); // Default 10 codes
});
});
describe('hasBackupCodes', () => {
it('should return true when user has unused backup codes', async () => {
it('should return true when user has backup codes', async () => {
// Arrange
const userId = 'user-123';
const codes = ['code1'];
mockPrisma.backupCode.findMany.mockResolvedValue([
{ id: 'code-1', used: false },
]);
mockPrisma.user.findUnique.mockResolvedValue({
id: userId,
mfaBackupCodes: 'encrypted-codes',
});
mockEncryptionService.decrypt.mockReturnValue(JSON.stringify(codes));
// Act
const result = await mfaService.hasBackupCodes(userId);
@@ -417,11 +400,14 @@ describe('MFAService', () => {
expect(result).toBe(true);
});
it('should return false when user has no unused backup codes', async () => {
it('should return false when user has no backup codes', async () => {
// Arrange
const userId = 'user-123';
mockPrisma.backupCode.findMany.mockResolvedValue([]);
mockPrisma.user.findUnique.mockResolvedValue({
id: userId,
mfaBackupCodes: null,
});
// Act
const result = await mfaService.hasBackupCodes(userId);

View File

@@ -13,10 +13,10 @@ jest.mock('@goodgo/logger');
describe('RBACService', () => {
let rbacService: RBACService;
let mockPrisma: jest.Mocked<PrismaClient>;
let mockUserRepo: jest.Mocked<UserRepository>;
let mockRoleRepo: jest.Mocked<RoleRepository>;
let mockPermissionRepo: jest.Mocked<PermissionRepository>;
let mockPrisma: any;
let mockUserRepo: any;
let mockRoleRepo: any;
let mockPermissionRepo: any;
let mockCacheService: any;
beforeEach(() => {
@@ -24,23 +24,26 @@ describe('RBACService', () => {
jest.clearAllMocks();
// Setup mocks
mockPrisma = {} as jest.Mocked<PrismaClient>;
mockPrisma = {
userRole: {
upsert: jest.fn(),
deleteMany: jest.fn(),
},
userPermission: {
upsert: jest.fn(),
},
} as any;
mockUserRepo = {
findWithPermissions: jest.fn(),
findWithRoles: jest.fn(),
} as any;
};
mockRoleRepo = {
assignRoleToUser: jest.fn(),
revokeRoleFromUser: jest.fn(),
getRolePermissions: jest.fn(),
} as any;
// Repos used for constructor but maybe not used for mutations
};
mockPermissionRepo = {
grantUserPermission: jest.fn(),
denyUserPermission: jest.fn(),
} as any;
mockPermissionRepo = {};
mockCacheService = {
keys: {
@@ -50,6 +53,8 @@ describe('RBACService', () => {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
del: jest.fn(),
delMany: jest.fn(),
};
// Mock the database client
@@ -303,16 +308,26 @@ describe('RBACService', () => {
const userId = 'user-123';
const roleId = 'role-admin';
mockRoleRepo.assignRoleToUser.mockResolvedValue(undefined);
mockCacheService.delete.mockResolvedValue(undefined);
mockPrisma.userRole.upsert.mockResolvedValue(undefined);
mockCacheService.delMany.mockResolvedValue(undefined);
// Act
await rbacService.assignRole(userId, roleId);
// Assert
expect(mockRoleRepo.assignRoleToUser).toHaveBeenCalledWith(userId, roleId);
expect(mockCacheService.delete).toHaveBeenCalledWith('user:permissions:123');
expect(mockCacheService.delete).toHaveBeenCalledWith('user:roles:123');
expect(mockPrisma.userRole.upsert).toHaveBeenCalledWith({
where: { userId_roleId: { userId, roleId } },
create: {
userId,
roleId,
grantedBy: undefined,
expiresAt: undefined,
},
update: {
expiresAt: undefined,
},
});
expect(mockCacheService.delMany).toHaveBeenCalled();
});
});
@@ -322,16 +337,17 @@ describe('RBACService', () => {
const userId = 'user-123';
const roleId = 'role-admin';
mockRoleRepo.revokeRoleFromUser.mockResolvedValue(undefined);
mockCacheService.delete.mockResolvedValue(undefined);
mockPrisma.userRole.deleteMany.mockResolvedValue(undefined);
mockCacheService.delMany.mockResolvedValue(undefined);
// Act
await rbacService.revokeRole(userId, roleId);
// Assert
expect(mockRoleRepo.revokeRoleFromUser).toHaveBeenCalledWith(userId, roleId);
expect(mockCacheService.delete).toHaveBeenCalledWith('user:permissions:123');
expect(mockCacheService.delete).toHaveBeenCalledWith('user:roles:123');
expect(mockPrisma.userRole.deleteMany).toHaveBeenCalledWith({
where: { userId, roleId },
});
expect(mockCacheService.delMany).toHaveBeenCalled();
});
});
@@ -339,24 +355,35 @@ describe('RBACService', () => {
it('should grant permission to user successfully', async () => {
// Arrange
const userId = 'user-123';
const resource = 'users';
const permissionId = 'perm-1';
const resource = 'users'; // Unused in direct implementation but passed in test
const action = 'read';
const scope = 'own';
mockPermissionRepo.grantUserPermission.mockResolvedValue(undefined);
mockCacheService.delete.mockResolvedValue(undefined);
mockPrisma.userPermission.upsert.mockResolvedValue(undefined);
mockCacheService.del.mockResolvedValue(undefined);
// Act
await rbacService.grantPermission(userId, resource, action, scope);
// Note: service.grantPermission takes (userId, permissionId, options)
// The test previously passed (userId, resource, action, scope) which is WRONG for the actual service signature
// Signature: grantPermission(userId: string, permissionId: string, options?: ...)
await rbacService.grantPermission(userId, permissionId);
// Assert
expect(mockPermissionRepo.grantUserPermission).toHaveBeenCalledWith(
userId,
resource,
action,
scope
);
expect(mockCacheService.delete).toHaveBeenCalledWith('user:permissions:123');
expect(mockPrisma.userPermission.upsert).toHaveBeenCalledWith({
where: { userId_permissionId: { userId, permissionId } },
create: {
userId,
permissionId,
granted: true,
grantedBy: undefined,
expiresAt: undefined,
},
update: {
granted: true,
expiresAt: undefined,
},
});
expect(mockCacheService.del).toHaveBeenCalled();
});
});
@@ -364,22 +391,28 @@ describe('RBACService', () => {
it('should deny permission from user successfully', async () => {
// Arrange
const userId = 'user-123';
const resource = 'users';
const action = 'write';
const permissionId = 'perm-1';
mockPermissionRepo.denyUserPermission.mockResolvedValue(undefined);
mockCacheService.delete.mockResolvedValue(undefined);
mockPrisma.userPermission.upsert.mockResolvedValue(undefined);
mockCacheService.del.mockResolvedValue(undefined);
// Act
await rbacService.denyPermission(userId, resource, action);
await rbacService.denyPermission(userId, permissionId);
// Assert
expect(mockPermissionRepo.denyUserPermission).toHaveBeenCalledWith(
userId,
resource,
action
);
expect(mockCacheService.delete).toHaveBeenCalledWith('user:permissions:123');
expect(mockPrisma.userPermission.upsert).toHaveBeenCalledWith({
where: { userId_permissionId: { userId, permissionId } },
create: {
userId,
permissionId,
granted: false,
grantedBy: undefined,
},
update: {
granted: false,
},
});
expect(mockCacheService.del).toHaveBeenCalled();
});
});
});

View File

@@ -150,9 +150,9 @@ describe('Helpers', () => {
},
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('192.168.1.100');
});
@@ -162,9 +162,9 @@ describe('Helpers', () => {
'x-forwarded-for': ['192.168.1.100', '10.0.0.1'],
},
ip: '127.0.0.1',
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('192.168.1.100');
});
@@ -175,9 +175,9 @@ describe('Helpers', () => {
},
ip: '127.0.0.1',
socket: { remoteAddress: '127.0.0.1' },
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('10.0.0.50');
});
@@ -187,9 +187,9 @@ describe('Helpers', () => {
'x-real-ip': ['10.0.0.50'],
},
ip: '127.0.0.1',
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('10.0.0.50');
});
@@ -198,9 +198,9 @@ describe('Helpers', () => {
headers: {},
ip: '172.16.0.25',
socket: { remoteAddress: '127.0.0.1' },
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('172.16.0.25');
});
@@ -208,18 +208,18 @@ describe('Helpers', () => {
const mockReq = {
headers: {},
socket: { remoteAddress: '203.0.113.1' },
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('203.0.113.1');
});
it('should return unknown when no IP found', () => {
const mockReq = {
headers: {},
} as Partial<Request>;
} as unknown as Request;
const result = getClientIP(mockReq as Request);
const result = getClientIP(mockReq);
expect(result).toBe('unknown');
});
});