This commit introduces a caching mechanism for user permissions using Redis, enhancing performance by reducing database queries. The `permissions.js` file has been updated to include functions for saving and retrieving user permissions from Redis, as well as middleware for checking permissions in various routes. Additionally, tests have been added to ensure the correctness of the permission logic and caching behavior, improving the overall security and efficiency of the application.
177 lines
5.1 KiB
JavaScript
177 lines
5.1 KiB
JavaScript
import { jest } from '@jest/globals';
|
|
|
|
const redisMock = {
|
|
getKey: jest.fn(),
|
|
setKey: jest.fn(),
|
|
};
|
|
|
|
const lean = jest.fn();
|
|
const select = jest.fn(() => ({ lean }));
|
|
const findById = jest.fn(() => ({ select }));
|
|
const userModel = { findById };
|
|
|
|
jest.unstable_mockModule('../redis.js', () => ({
|
|
redisServer: redisMock,
|
|
}));
|
|
|
|
jest.unstable_mockModule('mongoose', () => ({
|
|
default: {
|
|
model: jest.fn(() => userModel),
|
|
},
|
|
}));
|
|
|
|
const {
|
|
applyPermissionSettingsList,
|
|
checkPermissions,
|
|
hasPermission,
|
|
getUserPermissionsCacheKey,
|
|
saveUserPermissionsToRedis,
|
|
} = await import('../permissions.js');
|
|
|
|
describe('applyPermissionSettingsList', () => {
|
|
it('applies permission settings in order and inherits intermediate values', () => {
|
|
const result = applyPermissionSettingsList([
|
|
{
|
|
permissions: {
|
|
user: { info: true, edit: false, delete: true },
|
|
printer: { info: true },
|
|
},
|
|
},
|
|
{
|
|
permissions: {
|
|
user: { edit: true, delete: null },
|
|
printer: { info: null, delete: false },
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
user: { info: true, edit: true, delete: true },
|
|
printer: { info: true, delete: false },
|
|
});
|
|
});
|
|
|
|
it('returns an empty object when nothing is explicitly set', () => {
|
|
expect(applyPermissionSettingsList([{ permissions: { user: { info: null } } }])).toEqual({});
|
|
});
|
|
|
|
it('applies group permissions first, then user permission settings', () => {
|
|
const result = applyPermissionSettingsList([
|
|
{
|
|
permissions: {
|
|
user: { info: true, edit: false },
|
|
printer: { info: true },
|
|
},
|
|
},
|
|
{
|
|
permissions: {
|
|
user: { delete: true },
|
|
},
|
|
},
|
|
{
|
|
permissions: {
|
|
user: { edit: true },
|
|
printer: { info: false },
|
|
},
|
|
},
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
user: { info: true, edit: true, delete: true },
|
|
printer: { info: false },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('saveUserPermissionsToRedis', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it('saves permissions under permissions:userID', async () => {
|
|
const permissions = { user: { info: true } };
|
|
|
|
await saveUserPermissionsToRedis('user-1', permissions);
|
|
|
|
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-1', permissions);
|
|
});
|
|
});
|
|
|
|
describe('hasPermission', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
redisMock.getKey.mockResolvedValue(null);
|
|
redisMock.setKey.mockResolvedValue(undefined);
|
|
lean.mockResolvedValue(null);
|
|
});
|
|
|
|
it('returns true when the cached permission is allowed', async () => {
|
|
redisMock.getKey.mockResolvedValue({ printer: { edit: true } });
|
|
|
|
await expect(hasPermission({ _id: 'user-1' }, 'printer', 'edit')).resolves.toBe(true);
|
|
expect(redisMock.getKey).toHaveBeenCalledWith(getUserPermissionsCacheKey('user-1'));
|
|
expect(findById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns false when the cached permission is denied or missing', async () => {
|
|
redisMock.getKey.mockResolvedValue({ printer: { edit: false } });
|
|
|
|
await expect(hasPermission('user-1', 'printer', 'edit')).resolves.toBe(false);
|
|
await expect(hasPermission('user-1', 'printer', 'delete')).resolves.toBe(false);
|
|
});
|
|
|
|
it('loads permissions from mongodb when redis is empty and caches them', async () => {
|
|
lean.mockResolvedValue({
|
|
permissions: { job: { info: true, edit: false } },
|
|
});
|
|
|
|
await expect(hasPermission('user-2', 'job', 'info')).resolves.toBe(true);
|
|
expect(findById).toHaveBeenCalledWith('user-2');
|
|
expect(select).toHaveBeenCalledWith('permissions');
|
|
expect(redisMock.setKey).toHaveBeenCalledWith('permissions:user-2', {
|
|
job: { info: true, edit: false },
|
|
});
|
|
});
|
|
|
|
it('returns false when the user or action cannot be resolved', async () => {
|
|
await expect(hasPermission(null, 'job', 'info')).resolves.toBe(false);
|
|
await expect(hasPermission('user-1', '', 'info')).resolves.toBe(false);
|
|
expect(redisMock.getKey).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('checkPermissions middleware', () => {
|
|
const json = jest.fn();
|
|
const status = jest.fn(() => ({ json }));
|
|
const next = jest.fn();
|
|
const res = { status };
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
redisMock.getKey.mockResolvedValue(null);
|
|
redisMock.setKey.mockResolvedValue(undefined);
|
|
lean.mockResolvedValue(null);
|
|
});
|
|
|
|
it('calls next when the user is allowed', async () => {
|
|
redisMock.getKey.mockResolvedValue({ part: { edit: true } });
|
|
const req = { user: { _id: 'user-1' } };
|
|
|
|
await checkPermissions('part', 'edit')(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalledWith();
|
|
expect(status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 403 when the user is not allowed', async () => {
|
|
redisMock.getKey.mockResolvedValue({ part: { edit: false } });
|
|
const req = { user: { _id: 'user-1' } };
|
|
|
|
await checkPermissions('part', 'edit')(req, res, next);
|
|
|
|
expect(status).toHaveBeenCalledWith(403);
|
|
expect(json).toHaveBeenCalledWith({ error: 'Forbidden', code: 'FORBIDDEN' });
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
});
|