diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index dd34dd8..089b7ca 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -425,6 +425,27 @@ components: - description type: object additionalProperties: false + _LeanDocument__LeanDocument_T__: + properties: {} + type: object + Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__: + properties: + id: + description: 'The string version of this documents _id.' + _id: + $ref: '#/components/schemas/_LeanDocument__LeanDocument_T__' + description: 'This documents _id.' + __v: + description: 'This documents __v.' + type: object + description: 'From T, pick a set of properties whose keys are in the union K' + Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_: + $ref: '#/components/schemas/Pick__LeanDocument_T_.Exclude_keyof_LeanDocument_T_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested__' + description: 'Construct a type with the properties of T except for those in type K.' + LeanDocument_this_: + $ref: '#/components/schemas/Omit__LeanDocument_this_.Exclude_keyofDocument._id-or-id-or-__v_-or-%24isSingleNested_' + IGroup: + $ref: '#/components/schemas/LeanDocument_this_' InfoResponse: properties: mode: @@ -1215,6 +1236,30 @@ paths: application/json: schema: $ref: '#/components/schemas/GroupPayload' + '/SASjsApi/group/by/groupname/{name}': + get: + operationId: GetGroupByGroupName + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/GroupDetailsResponse' + summary: 'Get list of members of a group (userName). All users can request this.' + tags: + - Group + security: + - + bearerAuth: [] + parameters: + - + description: 'The group''s name' + in: path + name: name + required: true + schema: + type: string '/SASjsApi/group/{groupId}': get: operationId: GetGroup @@ -1244,8 +1289,14 @@ paths: delete: operationId: DeleteGroup responses: - '204': - description: 'No content' + '200': + description: Ok + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/IGroup'} + - {properties: {_id: {}}, required: [_id], type: object} summary: 'Delete a group. Admin task only.' tags: - Group diff --git a/api/src/controllers/group.ts b/api/src/controllers/group.ts index b3cbb0f..ca48360 100644 --- a/api/src/controllers/group.ts +++ b/api/src/controllers/group.ts @@ -28,6 +28,11 @@ interface GroupDetailsResponse { users: UserResponse[] } +interface GetGroupBy { + groupId?: number + name?: string +} + @Security('bearerAuth') @Route('SASjsApi/group') @Tags('Group') @@ -66,6 +71,18 @@ export class GroupController { return createGroup(body) } + /** + * @summary Get list of members of a group (userName). All users can request this. + * @param name The group's name + * @example dcgroup + */ + @Get('by/groupname/{name}') + public async getGroupByGroupName( + @Path() name: string + ): Promise { + return getGroup({ name }) + } + /** * @summary Get list of members of a group (userName). All users can request this. * @param groupId The group's identifier @@ -75,7 +92,7 @@ export class GroupController { public async getGroup( @Path() groupId: number ): Promise { - return getGroup(groupId) + return getGroup({ groupId }) } /** @@ -129,8 +146,8 @@ export class GroupController { */ @Delete('{groupId}') public async deleteGroup(@Path() groupId: number) { - const { deletedCount } = await Group.deleteOne({ groupId }) - if (deletedCount) return + const group = await Group.findOne({ groupId }) + if (group) return await group.remove() throw new Error('No Group deleted!') } } @@ -162,9 +179,9 @@ const createGroup = async ({ } } -const getGroup = async (groupId: number): Promise => { +const getGroup = async (findBy: GetGroupBy): Promise => { const group = (await Group.findOne( - { groupId }, + findBy, 'groupId name description isActive users -_id' ).populate( 'users', diff --git a/api/src/model/Group.ts b/api/src/model/Group.ts index 8ff04ff..36b7842 100644 --- a/api/src/model/Group.ts +++ b/api/src/model/Group.ts @@ -1,4 +1,5 @@ import mongoose, { Schema, model, Document, Model } from 'mongoose' +import User from './User' const AutoIncrement = require('mongoose-sequence')(mongoose) export interface GroupPayload { @@ -34,7 +35,8 @@ interface IGroupModel extends Model {} const groupSchema = new Schema({ name: { type: String, - required: true + required: true, + unique: true }, description: { type: String, @@ -46,6 +48,7 @@ const groupSchema = new Schema({ }, users: [{ type: Schema.Types.ObjectId, ref: 'User' }] }) + groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' }) // Hooks @@ -55,6 +58,17 @@ groupSchema.post('save', function (group: IGroup, next: Function) { }) }) +// pre remove hook to remove all references of group from users +groupSchema.pre('remove', async function () { + const userIds = this.users + await Promise.all( + userIds.map(async (userId) => { + const user = await User.findById(userId) + user?.removeGroup(this._id) + }) + ) +}) + // Instance Methods groupSchema.method( 'addUser', diff --git a/api/src/routes/api/group.ts b/api/src/routes/api/group.ts index 8164262..eaf4db5 100644 --- a/api/src/routes/api/group.ts +++ b/api/src/routes/api/group.ts @@ -1,7 +1,7 @@ import express from 'express' import { GroupController } from '../../controllers/' import { authenticateAccessToken, verifyAdmin } from '../../middlewares' -import { registerGroupValidation } from '../../utils' +import { getGroupValidation, registerGroupValidation } from '../../utils' const groupRouter = express.Router() @@ -45,6 +45,25 @@ groupRouter.get('/:groupId', authenticateAccessToken, async (req, res) => { } }) +groupRouter.get( + '/by/groupname/:name', + authenticateAccessToken, + async (req, res) => { + const { error, value: params } = getGroupValidation(req.params) + if (error) return res.status(400).send(error.details[0].message) + + const { name } = params + + const controller = new GroupController() + try { + const response = await controller.getGroupByGroupName(name) + res.send(response) + } catch (err: any) { + res.status(403).send(err.toString()) + } + } +) + groupRouter.post( '/:groupId/:userId', authenticateAccessToken, diff --git a/api/src/routes/api/spec/group.spec.ts b/api/src/routes/api/spec/group.spec.ts index b48bad8..cfa6036 100644 --- a/api/src/routes/api/spec/group.spec.ts +++ b/api/src/routes/api/spec/group.spec.ts @@ -23,7 +23,7 @@ const user = { } const group = { - name: 'DCGroup1', + name: 'dcgroup1', description: 'DC group for testing purposes.' } @@ -125,6 +125,43 @@ describe('group', () => { expect(res.body).toEqual({}) }) + it(`should delete group's reference from users' groups array`, async () => { + const dbGroup = await groupController.createGroup(group) + const dbUser1 = await userController.createUser({ + ...user, + username: 'deletegroup1' + }) + const dbUser2 = await userController.createUser({ + ...user, + username: 'deletegroup2' + }) + + await groupController.addUserToGroup(dbGroup.groupId, dbUser1.id) + await groupController.addUserToGroup(dbGroup.groupId, dbUser2.id) + + await request(app) + .delete(`/SASjsApi/group/${dbGroup.groupId}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + const res1 = await request(app) + .get(`/SASjsApi/user/${dbUser1.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res1.body.groups).toEqual([]) + + const res2 = await request(app) + .get(`/SASjsApi/user/${dbUser2.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res2.body.groups).toEqual([]) + }) + it('should respond with Forbidden if groupId is incorrect', async () => { const res = await request(app) .delete(`/SASjsApi/group/1234`) @@ -226,6 +263,66 @@ describe('group', () => { expect(res.text).toEqual('Error: Group not found.') expect(res.body).toEqual({}) }) + + describe('by group name', () => { + it('should respond with group', async () => { + const { name } = await groupController.createGroup(group) + + const res = await request(app) + .get(`/SASjsApi/group/by/groupname/${name}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.groupId).toBeTruthy() + expect(res.body.name).toEqual(group.name) + expect(res.body.description).toEqual(group.description) + expect(res.body.isActive).toEqual(true) + expect(res.body.users).toEqual([]) + }) + + it('should respond with group when access token is not of an admin account', async () => { + const accessToken = await generateSaveTokenAndCreateUser({ + ...user, + username: 'getbyname' + user.username + }) + + const { name } = await groupController.createGroup(group) + + const res = await request(app) + .get(`/SASjsApi/group/by/groupname/${name}`) + .auth(accessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.groupId).toBeTruthy() + expect(res.body.name).toEqual(group.name) + expect(res.body.description).toEqual(group.description) + expect(res.body.isActive).toEqual(true) + expect(res.body.users).toEqual([]) + }) + + it('should respond with Unauthorized if access token is not present', async () => { + const res = await request(app) + .get('/SASjsApi/group/by/groupname/dcgroup') + .send() + .expect(401) + + expect(res.text).toEqual('Unauthorized') + expect(res.body).toEqual({}) + }) + + it('should respond with Forbidden if groupname is incorrect', async () => { + const res = await request(app) + .get('/SASjsApi/group/by/groupname/randomCharacters') + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(403) + + expect(res.text).toEqual('Error: Group not found.') + expect(res.body).toEqual({}) + }) + }) }) describe('getAll', () => { @@ -245,8 +342,8 @@ describe('group', () => { expect(res.body).toEqual([ { groupId: expect.anything(), - name: 'DCGroup1', - description: 'DC group for testing purposes.' + name: group.name, + description: group.description } ]) }) @@ -267,8 +364,8 @@ describe('group', () => { expect(res.body).toEqual([ { groupId: expect.anything(), - name: 'DCGroup1', - description: 'DC group for testing purposes.' + name: group.name, + description: group.description } ]) }) @@ -309,6 +406,34 @@ describe('group', () => { ]) }) + it(`should add group to user's groups array`, async () => { + const dbGroup = await groupController.createGroup(group) + const dbUser = await userController.createUser({ + ...user, + username: 'addUserToGroup' + }) + + await request(app) + .post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + const res = await request(app) + .get(`/SASjsApi/user/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.groups).toEqual([ + { + groupId: expect.anything(), + name: group.name, + description: group.description + } + ]) + }) + it('should respond with group without duplicating user', async () => { const dbGroup = await groupController.createGroup(group) const dbUser = await userController.createUser({ @@ -412,6 +537,29 @@ describe('group', () => { expect(res.body.users).toEqual([]) }) + it(`should remove group from user's groups array`, async () => { + const dbGroup = await groupController.createGroup(group) + const dbUser = await userController.createUser({ + ...user, + username: 'removeGroupFromUser' + }) + await groupController.addUserToGroup(dbGroup.groupId, dbUser.id) + + await request(app) + .delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + const res = await request(app) + .get(`/SASjsApi/user/${dbUser.id}`) + .auth(adminAccessToken, { type: 'bearer' }) + .send() + .expect(200) + + expect(res.body.groups).toEqual([]) + }) + it('should respond with Unauthorized if access token is not present', async () => { const res = await request(app) .delete('/SASjsApi/group/123/123') diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index 3499715..d69ce12 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -2,6 +2,7 @@ import Joi from 'joi' const usernameSchema = Joi.string().lowercase().alphanum().min(3).max(16) const passwordSchema = Joi.string().min(6).max(1024) +const groupnameSchema = Joi.string().lowercase().alphanum().min(3).max(16) export const blockFileRegex = /\.(exe|sh|htaccess)$/i @@ -29,11 +30,16 @@ export const tokenValidation = (data: any): Joi.ValidationResult => export const registerGroupValidation = (data: any): Joi.ValidationResult => Joi.object({ - name: Joi.string().min(6).required(), + name: groupnameSchema.required(), description: Joi.string(), isActive: Joi.boolean() }).validate(data) +export const getGroupValidation = (data: any): Joi.ValidationResult => + Joi.object({ + name: groupnameSchema.required() + }).validate(data) + export const registerUserValidation = (data: any): Joi.ValidationResult => Joi.object({ displayName: Joi.string().min(6).required(),