[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:
Adrian Flores Cortes 2026-01-31 01:56:52 -06:00
parent 98fc0cf944
commit 6f0548bc5b
2 changed files with 792 additions and 0 deletions

View 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);
});
});
});

View 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();
});
});
});