diff --git a/src/shared/middleware/__tests__/rbac.middleware.test.ts b/src/shared/middleware/__tests__/rbac.middleware.test.ts new file mode 100644 index 0000000..ea1dc7b --- /dev/null +++ b/src/shared/middleware/__tests__/rbac.middleware.test.ts @@ -0,0 +1,430 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { Response, NextFunction } from 'express'; +import { AuthenticatedRequest, ForbiddenError, UnauthorizedError } from '../../types/index.js'; + +// Mock dependencies +const mockHasPermission = jest.fn(); +const mockGetEffectivePermissions = jest.fn(); +const mockCheckCachedPermission = jest.fn(); +const mockGetCachedPermissions = jest.fn(); +const mockSetCachedPermissions = jest.fn(); +const mockLoggerWarn = jest.fn(); +const mockLoggerDebug = jest.fn(); + +jest.mock('../../../modules/roles/permissions.service.js', () => ({ + permissionsService: { + hasPermission: (...args: any[]) => mockHasPermission(...args), + getEffectivePermissions: (...args: any[]) => mockGetEffectivePermissions(...args), + }, +})); + +jest.mock('../../services/permission-cache.service.js', () => ({ + checkCachedPermission: (...args: any[]) => mockCheckCachedPermission(...args), + getCachedPermissions: (...args: any[]) => mockGetCachedPermissions(...args), + setCachedPermissions: (...args: any[]) => mockSetCachedPermissions(...args), +})); + +jest.mock('../../utils/logger.js', () => ({ + logger: { + warn: (...args: any[]) => mockLoggerWarn(...args), + debug: (...args: any[]) => mockLoggerDebug(...args), + info: jest.fn(), + error: jest.fn(), + }, +})); + +// Import after mocking +import { + requirePerm, + requireAnyPerm, + requireAllPerms, + requireAccess, + requirePermOrOwner, +} from '../rbac.middleware.js'; + +describe('RBAC Middleware', () => { + let mockReq: Partial; + let mockRes: Partial; + let mockNext: NextFunction; + const tenantId = 'tenant-uuid-1'; + const userId = 'user-uuid-1'; + + beforeEach(() => { + jest.clearAllMocks(); + mockReq = { + user: { + userId, + tenantId, + email: 'test@test.com', + roles: ['manager'], + } as any, + params: {}, + body: {}, + }; + mockRes = { + status: jest.fn().mockReturnThis() as any, + json: jest.fn() as any, + }; + mockNext = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('requirePerm', () => { + it('should call next() when user has permission via cache', async () => { + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requirePerm('invoices:create'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledWith(tenantId, userId, 'invoices', 'create'); + expect(mockNext).toHaveBeenCalledWith(); + expect(mockHasPermission).not.toHaveBeenCalled(); + }); + + it('should call next() when user has permission via database (cache miss)', async () => { + mockCheckCachedPermission.mockResolvedValue(null); + mockHasPermission.mockResolvedValue(true); + mockGetEffectivePermissions.mockResolvedValue([ + { resource: 'invoices', action: 'create' }, + ]); + + const middleware = requirePerm('invoices:create'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledWith(tenantId, userId, 'invoices', 'create'); + expect(mockHasPermission).toHaveBeenCalledWith(tenantId, userId, 'invoices', 'create'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should throw ForbiddenError when user lacks permission', async () => { + mockCheckCachedPermission.mockResolvedValue(false); + + const middleware = requirePerm('invoices:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'Access denied (requirePerm)', + expect.objectContaining({ + userId, + tenantId, + permission: 'invoices:delete', + }) + ); + }); + + it('should bypass permission check for super_admin', async () => { + mockReq.user = { + userId, + tenantId, + email: 'super@test.com', + roles: ['super_admin'], + } as any; + + const middleware = requirePerm('invoices:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockHasPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should throw UnauthorizedError when user is not authenticated', async () => { + mockReq.user = undefined; + + const middleware = requirePerm('invoices:read'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(UnauthorizedError); + }); + + it('should support dot notation for permission codes', async () => { + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requirePerm('invoices.create'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledWith(tenantId, userId, 'invoices', 'create'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should warm up cache after database check when permission denied', async () => { + mockCheckCachedPermission.mockResolvedValue(null); + mockHasPermission.mockResolvedValue(false); + mockGetEffectivePermissions.mockResolvedValue([]); + + const middleware = requirePerm('invoices:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockSetCachedPermissions).toHaveBeenCalledWith(tenantId, userId, []); + }); + }); + + describe('requireAnyPerm', () => { + it('should call next() when user has at least one permission', async () => { + mockCheckCachedPermission + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const middleware = requireAnyPerm('invoices:delete', 'invoices:read'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should throw ForbiddenError when user lacks all permissions', async () => { + mockCheckCachedPermission.mockResolvedValue(false); + + const middleware = requireAnyPerm('invoices:delete', 'users:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'Access denied (requireAnyPerm)', + expect.objectContaining({ + permissions: ['invoices:delete', 'users:delete'], + }) + ); + }); + + it('should bypass for super_admin', async () => { + mockReq.user = { + userId, + tenantId, + email: 'super@test.com', + roles: ['super_admin'], + } as any; + + const middleware = requireAnyPerm('invoices:delete', 'users:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe('requireAllPerms', () => { + it('should call next() when user has all permissions', async () => { + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requireAllPerms('invoices:read', 'payments:read'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledTimes(2); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should throw ForbiddenError when user lacks any permission', async () => { + mockCheckCachedPermission + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + const middleware = requireAllPerms('invoices:read', 'invoices:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'Access denied (requireAllPerms)', + expect.objectContaining({ + required: ['invoices:read', 'invoices:delete'], + missing: ['invoices:delete'], + }) + ); + }); + + it('should bypass for super_admin', async () => { + mockReq.user = { + userId, + tenantId, + email: 'super@test.com', + roles: ['super_admin'], + } as any; + + const middleware = requireAllPerms('invoices:delete', 'users:delete', 'system:manage'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe('requireAccess (hybrid mode)', () => { + it('should allow access via permission', async () => { + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requireAccess({ roles: ['admin'], permission: 'invoices:create' }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledWith(tenantId, userId, 'invoices', 'create'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should fallback to role check when permission denied', async () => { + mockCheckCachedPermission.mockResolvedValue(null); + mockHasPermission.mockResolvedValue(false); + mockReq.user!.roles = ['admin']; + + const middleware = requireAccess({ roles: ['admin'], permission: 'invoices:create' }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Access granted via role (hybrid mode)', + expect.objectContaining({ + matchedRoles: ['admin'], + }) + ); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should deny access when neither permission nor role matches', async () => { + mockCheckCachedPermission.mockResolvedValue(false); + + const middleware = requireAccess({ roles: ['admin'], permission: 'invoices:delete' }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + }); + + it('should work with only roles specified', async () => { + mockReq.user!.roles = ['admin']; + + const middleware = requireAccess({ roles: ['admin', 'manager'] }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should work with only permission specified', async () => { + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requireAccess({ permission: 'invoices:read' }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should bypass for super_admin', async () => { + mockReq.user = { + userId, + tenantId, + email: 'super@test.com', + roles: ['super_admin'], + } as any; + + const middleware = requireAccess({ roles: ['admin'], permission: 'system:manage' }); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe('requirePermOrOwner', () => { + it('should allow access when user is the resource owner', async () => { + mockReq.params = { userId }; + + const middleware = requirePermOrOwner('users:update'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Access granted (owner)', + expect.objectContaining({ userId }) + ); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should check permission when user is not the owner', async () => { + mockReq.params = { userId: 'other-user-uuid' }; + mockCheckCachedPermission.mockResolvedValue(true); + + const middleware = requirePermOrOwner('users:update'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).toHaveBeenCalledWith(tenantId, userId, 'users', 'update'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should deny access when not owner and lacks permission', async () => { + mockReq.params = { userId: 'other-user-uuid' }; + mockCheckCachedPermission.mockResolvedValue(false); + + const middleware = requirePermOrOwner('users:update'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalled(); + const error = (mockNext as jest.Mock).mock.calls[0][0]; + expect(error).toBeInstanceOf(ForbiddenError); + }); + + it('should use custom owner field from params', async () => { + mockReq.params = { createdBy: userId }; + + const middleware = requirePermOrOwner('invoices:update', 'createdBy'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Access granted (owner)', + expect.any(Object) + ); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should check owner field in body if not in params', async () => { + mockReq.params = {}; + mockReq.body = { userId }; + + const middleware = requirePermOrOwner('users:update'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Access granted (owner)', + expect.any(Object) + ); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should bypass for super_admin', async () => { + mockReq.user = { + userId, + tenantId, + email: 'super@test.com', + roles: ['super_admin'], + } as any; + mockReq.params = { userId: 'other-user-uuid' }; + + const middleware = requirePermOrOwner('users:delete'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockCheckCachedPermission).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe('error handling', () => { + it('should pass errors to next() middleware', async () => { + const testError = new Error('Database connection failed'); + mockCheckCachedPermission.mockRejectedValue(testError); + + const middleware = requirePerm('invoices:read'); + await middleware(mockReq as AuthenticatedRequest, mockRes as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(testError); + }); + }); +}); diff --git a/src/shared/services/__tests__/permission-cache.service.test.ts b/src/shared/services/__tests__/permission-cache.service.test.ts new file mode 100644 index 0000000..10865bb --- /dev/null +++ b/src/shared/services/__tests__/permission-cache.service.test.ts @@ -0,0 +1,362 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; + +// Mock Redis client +const mockRedisGet = jest.fn(); +const mockRedisSetex = jest.fn(); +const mockRedisDel = jest.fn(); +const mockRedisScan = jest.fn(); +let mockIsConnected = true; + +jest.mock('../../../config/redis.js', () => ({ + redisClient: { + get: (...args: any[]) => mockRedisGet(...args), + setex: (...args: any[]) => mockRedisSetex(...args), + del: (...args: any[]) => mockRedisDel(...args), + scan: (...args: any[]) => mockRedisScan(...args), + }, + isRedisConnected: () => mockIsConnected, +})); + +const mockLoggerDebug = jest.fn(); +const mockLoggerInfo = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock('../../utils/logger.js', () => ({ + logger: { + debug: (...args: any[]) => mockLoggerDebug(...args), + info: (...args: any[]) => mockLoggerInfo(...args), + error: (...args: any[]) => mockLoggerError(...args), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { + getCachedPermissions, + setCachedPermissions, + invalidateUserPermissions, + invalidateTenantPermissions, + checkCachedPermission, + permissionCacheService, +} from '../permission-cache.service.js'; + +describe('Permission Cache Service', () => { + const tenantId = 'tenant-uuid-1'; + const userId = 'user-uuid-1'; + const mockPermissions = [ + { resource: 'invoices', action: 'create' }, + { resource: 'invoices', action: 'read' }, + { resource: 'partners', action: 'read' }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockIsConnected = true; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getCachedPermissions', () => { + it('should return null when Redis is not connected', async () => { + mockIsConnected = false; + + const result = await getCachedPermissions(tenantId, userId); + + expect(result).toBeNull(); + expect(mockRedisGet).not.toHaveBeenCalled(); + }); + + it('should return cached permissions on cache hit', async () => { + mockRedisGet.mockResolvedValue(JSON.stringify(mockPermissions)); + + const result = await getCachedPermissions(tenantId, userId); + + expect(mockRedisGet).toHaveBeenCalledWith(`permissions:${tenantId}:${userId}`); + expect(result).toEqual(mockPermissions); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Permission cache hit', + expect.objectContaining({ tenantId, userId }) + ); + }); + + it('should return null on cache miss', async () => { + mockRedisGet.mockResolvedValue(null); + + const result = await getCachedPermissions(tenantId, userId); + + expect(result).toBeNull(); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Permission cache miss', + expect.objectContaining({ tenantId, userId }) + ); + }); + + it('should return null and log error on Redis error', async () => { + const error = new Error('Redis connection failed'); + mockRedisGet.mockRejectedValue(error); + + const result = await getCachedPermissions(tenantId, userId); + + expect(result).toBeNull(); + expect(mockLoggerError).toHaveBeenCalledWith( + 'Error reading permission cache', + expect.objectContaining({ + error: 'Redis connection failed', + tenantId, + userId, + }) + ); + }); + }); + + describe('setCachedPermissions', () => { + it('should not set cache when Redis is not connected', async () => { + mockIsConnected = false; + + await setCachedPermissions(tenantId, userId, mockPermissions); + + expect(mockRedisSetex).not.toHaveBeenCalled(); + }); + + it('should cache permissions with 5 minute TTL', async () => { + await setCachedPermissions(tenantId, userId, mockPermissions); + + expect(mockRedisSetex).toHaveBeenCalledWith( + `permissions:${tenantId}:${userId}`, + 300, // 5 minutes TTL + JSON.stringify(mockPermissions) + ); + expect(mockLoggerDebug).toHaveBeenCalledWith( + 'Permissions cached', + expect.objectContaining({ + tenantId, + userId, + count: mockPermissions.length, + ttl: 300, + }) + ); + }); + + it('should log error on Redis error', async () => { + const error = new Error('Redis write failed'); + mockRedisSetex.mockRejectedValue(error); + + await setCachedPermissions(tenantId, userId, mockPermissions); + + expect(mockLoggerError).toHaveBeenCalledWith( + 'Error caching permissions', + expect.objectContaining({ + error: 'Redis write failed', + tenantId, + userId, + }) + ); + }); + }); + + describe('invalidateUserPermissions', () => { + it('should not delete when Redis is not connected', async () => { + mockIsConnected = false; + + await invalidateUserPermissions(tenantId, userId); + + expect(mockRedisDel).not.toHaveBeenCalled(); + }); + + it('should delete user permission cache', async () => { + await invalidateUserPermissions(tenantId, userId); + + expect(mockRedisDel).toHaveBeenCalledWith(`permissions:${tenantId}:${userId}`); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'User permission cache invalidated', + expect.objectContaining({ tenantId, userId }) + ); + }); + + it('should log error on Redis error', async () => { + const error = new Error('Redis delete failed'); + mockRedisDel.mockRejectedValue(error); + + await invalidateUserPermissions(tenantId, userId); + + expect(mockLoggerError).toHaveBeenCalledWith( + 'Error invalidating user permission cache', + expect.objectContaining({ + error: 'Redis delete failed', + tenantId, + userId, + }) + ); + }); + }); + + describe('invalidateTenantPermissions', () => { + it('should not scan when Redis is not connected', async () => { + mockIsConnected = false; + + await invalidateTenantPermissions(tenantId); + + expect(mockRedisScan).not.toHaveBeenCalled(); + }); + + it('should delete all tenant permission caches', async () => { + // Simulate scan returning keys in one batch + mockRedisScan.mockResolvedValueOnce([ + '0', // cursor = 0 means done + [ + `permissions:${tenantId}:user1`, + `permissions:${tenantId}:user2`, + `permissions:${tenantId}:user3`, + ], + ]); + + await invalidateTenantPermissions(tenantId); + + expect(mockRedisScan).toHaveBeenCalledWith( + '0', + 'MATCH', + `permissions:${tenantId}:*`, + 'COUNT', + 100 + ); + expect(mockRedisDel).toHaveBeenCalledWith( + `permissions:${tenantId}:user1`, + `permissions:${tenantId}:user2`, + `permissions:${tenantId}:user3` + ); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Tenant permission cache invalidated', + expect.objectContaining({ + tenantId, + keysDeleted: 3, + }) + ); + }); + + it('should handle multiple scan batches', async () => { + // First batch + mockRedisScan.mockResolvedValueOnce([ + '42', // cursor for next batch + [`permissions:${tenantId}:user1`], + ]); + // Second batch (final) + mockRedisScan.mockResolvedValueOnce([ + '0', // cursor = 0 means done + [`permissions:${tenantId}:user2`], + ]); + + await invalidateTenantPermissions(tenantId); + + expect(mockRedisScan).toHaveBeenCalledTimes(2); + expect(mockRedisDel).toHaveBeenCalledTimes(2); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Tenant permission cache invalidated', + expect.objectContaining({ + keysDeleted: 2, + }) + ); + }); + + it('should handle empty tenant (no cached permissions)', async () => { + mockRedisScan.mockResolvedValueOnce(['0', []]); + + await invalidateTenantPermissions(tenantId); + + expect(mockRedisDel).not.toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'Tenant permission cache invalidated', + expect.objectContaining({ keysDeleted: 0 }) + ); + }); + + it('should log error on Redis error', async () => { + const error = new Error('Redis scan failed'); + mockRedisScan.mockRejectedValue(error); + + await invalidateTenantPermissions(tenantId); + + expect(mockLoggerError).toHaveBeenCalledWith( + 'Error invalidating tenant permission cache', + expect.objectContaining({ + error: 'Redis scan failed', + tenantId, + }) + ); + }); + }); + + describe('checkCachedPermission', () => { + it('should return null when permissions not cached', async () => { + mockRedisGet.mockResolvedValue(null); + + const result = await checkCachedPermission(tenantId, userId, 'invoices', 'create'); + + expect(result).toBeNull(); + }); + + it('should return true when permission exists in cache', async () => { + mockRedisGet.mockResolvedValue(JSON.stringify(mockPermissions)); + + const result = await checkCachedPermission(tenantId, userId, 'invoices', 'create'); + + expect(result).toBe(true); + }); + + it('should return false when permission not in cache', async () => { + mockRedisGet.mockResolvedValue(JSON.stringify(mockPermissions)); + + const result = await checkCachedPermission(tenantId, userId, 'invoices', 'delete'); + + expect(result).toBe(false); + }); + + it('should return null when Redis not connected', async () => { + mockIsConnected = false; + + const result = await checkCachedPermission(tenantId, userId, 'invoices', 'create'); + + expect(result).toBeNull(); + }); + }); + + describe('permissionCacheService object', () => { + it('should expose all methods', () => { + expect(permissionCacheService.get).toBe(getCachedPermissions); + expect(permissionCacheService.set).toBe(setCachedPermissions); + expect(permissionCacheService.invalidateUser).toBe(invalidateUserPermissions); + expect(permissionCacheService.invalidateTenant).toBe(invalidateTenantPermissions); + expect(permissionCacheService.checkPermission).toBe(checkCachedPermission); + }); + }); + + describe('Redis unavailable fallback', () => { + it('should gracefully handle Redis being unavailable for reads', async () => { + mockIsConnected = false; + + const result = await getCachedPermissions(tenantId, userId); + + expect(result).toBeNull(); + expect(mockLoggerError).not.toHaveBeenCalled(); + }); + + it('should gracefully handle Redis being unavailable for writes', async () => { + mockIsConnected = false; + + await setCachedPermissions(tenantId, userId, mockPermissions); + + expect(mockRedisSetex).not.toHaveBeenCalled(); + expect(mockLoggerError).not.toHaveBeenCalled(); + }); + + it('should gracefully handle Redis being unavailable for invalidation', async () => { + mockIsConnected = false; + + await invalidateUserPermissions(tenantId, userId); + + expect(mockRedisDel).not.toHaveBeenCalled(); + expect(mockLoggerError).not.toHaveBeenCalled(); + }); + }); +});