[RBAC-003] test: Add RBAC middleware and cache service unit tests
Phase 4 testing implementation: - rbac.middleware.test.ts: Tests for requirePerm, requireAnyPerm, requireAllPerms, requireAccess, requirePermOrOwner - permission-cache.service.test.ts: Tests for cache operations, Redis unavailable fallback, tenant-wide invalidation Test scenarios covered: - User with/without permission (403) - Super admin bypass - Cache hit/miss - Redis unavailable fallback - Hybrid access control (role + permission) - Owner-based access Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
98fc0cf944
commit
6f0548bc5b
430
src/shared/middleware/__tests__/rbac.middleware.test.ts
Normal file
430
src/shared/middleware/__tests__/rbac.middleware.test.ts
Normal file
@ -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<AuthenticatedRequest>;
|
||||
let mockRes: Partial<Response>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
362
src/shared/services/__tests__/permission-cache.service.test.ts
Normal file
362
src/shared/services/__tests__/permission-cache.service.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user