From 4ffdf98e16bd4173320fd23f7ae942bf327b0427 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Tue, 19 Nov 2024 15:46:53 +0000 Subject: [PATCH] test(user): write tests for ability and can --- .../backend/src/helpers/user-ability.test.js | 46 ++++++++++++++ packages/backend/src/models/user.js | 46 ++++++-------- packages/backend/src/models/user.test.js | 60 +++++++++++++++++++ 3 files changed, 125 insertions(+), 27 deletions(-) create mode 100644 packages/backend/src/helpers/user-ability.test.js diff --git a/packages/backend/src/helpers/user-ability.test.js b/packages/backend/src/helpers/user-ability.test.js new file mode 100644 index 00000000..906a5fb3 --- /dev/null +++ b/packages/backend/src/helpers/user-ability.test.js @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import userAbility from './user-ability.js'; + +describe('userAbility', () => { + it('should return PureAbility instantiated with user permissions', () => { + const user = { + permissions: [ + { + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }, + ], + role: { + name: 'User', + }, + }; + + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual(user.permissions); + }); + + it('should return permission-less PureAbility for user with no role', () => { + const user = { + permissions: [ + { + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }, + ], + role: null, + }; + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual([]); + }); + + it('should return permission-less PureAbility for user with no permissions', () => { + const user = { permissions: null, role: { name: 'User' } }; + const ability = userAbility(user); + + expect(ability.rules).toStrictEqual([]); + }); +}); diff --git a/packages/backend/src/models/user.js b/packages/backend/src/models/user.js index a2558dc1..e75c0c40 100644 --- a/packages/backend/src/models/user.js +++ b/packages/backend/src/models/user.js @@ -212,6 +212,10 @@ class User extends Base { return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`; } + get ability() { + return userAbility(this); + } + static async authenticate(email, password) { const user = await User.query().findOne({ email: email?.toLowerCase() || null, @@ -583,6 +587,21 @@ class User extends Base { return user; } + can(action, subject) { + const can = this.ability.can(action, subject); + + if (!can) throw new NotAuthorizedError('The user is not authorized!'); + + const relevantRule = this.ability.relevantRuleFor(action, subject); + + const conditions = relevantRule?.conditions || []; + const conditionMap = Object.fromEntries( + conditions.map((condition) => [condition, true]) + ); + + return conditionMap; + } + async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); @@ -634,33 +653,6 @@ class User extends Base { return this; } - - get ability() { - return userAbility(this); - } - - can(action, subject) { - const can = this.ability.can(action, subject); - - if (!can) throw new NotAuthorizedError('The user is not authorized!'); - - const relevantRule = this.ability.relevantRuleFor(action, subject); - - const conditions = relevantRule?.conditions || []; - const conditionMap = Object.fromEntries( - conditions.map((condition) => [condition, true]) - ); - - return conditionMap; - } - - cannot(action, subject) { - const cannot = this.ability.cannot(action, subject); - - if (cannot) throw new NotAuthorizedError(); - - return cannot; - } } export default User; diff --git a/packages/backend/src/models/user.test.js b/packages/backend/src/models/user.test.js index 2112c4fd..51fd8438 100644 --- a/packages/backend/src/models/user.test.js +++ b/packages/backend/src/models/user.test.js @@ -20,6 +20,7 @@ import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_7_DAYS_OR_50_JOBS, } from '../helpers/remove-job-configuration.js'; +import * as userAbilityModule from '../helpers/user-ability.js'; import { createUser } from '../../test/factories/user.js'; import { createConnection } from '../../test/factories/connection.js'; import { createRole } from '../../test/factories/role.js'; @@ -218,6 +219,18 @@ describe('User model', () => { ); }); + it('ability should return userAbility for the user', () => { + const user = new User(); + user.fullName = 'Sample user'; + + const userAbilitySpy = vi + .spyOn(userAbilityModule, 'default') + .mockReturnValue('user-ability'); + + expect(user.ability).toStrictEqual('user-ability'); + expect(userAbilitySpy).toHaveBeenNthCalledWith(1, user); + }); + describe('authenticate', () => { it('should create and return the token for correct email and password', async () => { const user = await createUser({ @@ -1184,4 +1197,51 @@ describe('User model', () => { ).rejects.toThrowError('NotFoundError'); }); }); + + describe('can', () => { + it('should return conditions for the given action and subject of the user', async () => { + const userRole = await createRole({ name: 'User' }); + + await createPermission({ + roleId: userRole.id, + subject: 'Flow', + action: 'read', + conditions: ['isCreator'], + }); + + await createPermission({ + roleId: userRole.id, + subject: 'Connection', + action: 'read', + conditions: [], + }); + + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(userWithRoleAndPermissions.can('read', 'Flow')).toStrictEqual({ + isCreator: true, + }); + + expect( + userWithRoleAndPermissions.can('read', 'Connection') + ).toStrictEqual({}); + }); + + it('should return not authorized error when the user is not permitted for the given action and subject', async () => { + const userRole = await createRole({ name: 'User' }); + const user = await createUser({ roleId: userRole.id }); + + const userWithRoleAndPermissions = await user + .$query() + .withGraphFetched({ role: true, permissions: true }); + + expect(() => userWithRoleAndPermissions.can('read', 'Flow')).toThrowError( + 'The user is not authorized!' + ); + }); + }); });