diff --git a/CHANGELOG.md b/CHANGELOG.md index 747536a0..33ebe8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Another example [here](https://co-pilot.dev/changelog) ### Added - Add units tests for the teams controller & services([#189](https://github.com/chingu-x/chingu-dashboard-be/pull/189)) - Add discord oauth and e2e test ([#194](https://github.com/chingu-x/chingu-dashboard-be/pull/194)) +- Add CASL permissions for Team Sprint endpoint ([#193](https://github.com/chingu-x/chingu-dashboard-be/pull/193)) + ### Changed - updated changelog ([#195](https://github.com/chingu-x/chingu-dashboard-be/pull/195)) diff --git a/src/ability/ability.factory/ability.factory.ts b/src/ability/ability.factory/ability.factory.ts index 15995336..2391cf6e 100644 --- a/src/ability/ability.factory/ability.factory.ts +++ b/src/ability/ability.factory/ability.factory.ts @@ -12,6 +12,7 @@ export enum Action { Update = "update", Delete = "delete", Submit = "submit", + ManageSprintForms = "manageSprintForms", } type ExtendedSubjects = "all"; @@ -25,33 +26,40 @@ export class AbilityFactory { createPrismaAbility, ); + const userVoyageTeamIds = user.voyageTeams.map((vt) => vt.teamId); + const userVoyageTeamMemberIds = user.voyageTeams.map( + (vt) => vt.memberId, + ); + if (user.roles.includes("admin")) { can(Action.Manage, "all"); } else if (user.roles.includes("voyager")) { can([Action.Submit], "Voyage"); can([Action.Manage], "VoyageTeam", { - id: { in: user.voyageTeams.map((vt) => vt.teamId) }, + id: { in: userVoyageTeamIds }, }); // For Ideation and Tech stack, we make the permission team based here // as there are times we'll need them to be able to manage other team members ideations/tech // more specific permission checks can be found in `ideations.ability.ts` etc can([Action.Manage], "Ideation", { voyageTeamMemberId: { - in: user.voyageTeams.map((vt) => vt.memberId), + in: userVoyageTeamMemberIds, }, }); can([Action.Submit, Action.Read], "Form"); can([Action.Manage], "TeamTechStackItem"); can([Action.Manage], "Resource", { teamMemberId: { - in: user.voyageTeams.map((vt) => vt.memberId), + in: userVoyageTeamMemberIds, }, }); can([Action.Manage], "Feature", { teamMemberId: { - in: user.voyageTeams.map((vt) => vt.memberId), + in: userVoyageTeamMemberIds, }, }); + can([Action.ManageSprintForms, Action.Read], "Sprint"); + can([Action.Manage], "SprintMeetingOrAgenda"); } else { // all other users can([Action.Submit, Action.Read], "Form", { diff --git a/src/ability/conditions/meetingOrAgenda.ability.ts b/src/ability/conditions/meetingOrAgenda.ability.ts new file mode 100644 index 00000000..8660e255 --- /dev/null +++ b/src/ability/conditions/meetingOrAgenda.ability.ts @@ -0,0 +1,71 @@ +import { ForbiddenException, NotFoundException } from "@nestjs/common"; +import { UserReq } from "../../global/types/CustomRequest"; +import prisma from "../../prisma/client"; +import { Agenda, TeamMeeting } from "@prisma/client"; + +export const manageOwnTeamMeetingOrAgendaById = async ({ + user, + meetingId, + agendaId, +}: { + user: UserReq; + meetingId?: number; + agendaId?: number; + subject?: TeamMeeting | Agenda; // If we want to extend some more permissions for any particular subject +}) => { + let meetingOrAgendaTeamId: number; + const voyagerTeamIds = user.voyageTeams.map((vt) => vt.teamId); + if (meetingId) { + const meeting = await prisma.teamMeeting.findUnique({ + where: { + id: meetingId, + }, + select: { + voyageTeamId: true, + }, + }); + if (!meeting) { + throw new NotFoundException( + `Team Meeting (id:${meetingId}) not found`, + ); + } + + meetingOrAgendaTeamId = meeting.voyageTeamId; + } + + if (agendaId) { + const agenda = await prisma.agenda.findUnique({ + where: { + id: agendaId, + }, + select: { + teamMeeting: { + select: { + voyageTeamId: true, + }, + }, + }, + }); + if (!agenda) { + throw new NotFoundException( + `Team Meeting Agenda (id:${agendaId}) not found`, + ); + } + + meetingOrAgendaTeamId = agenda.teamMeeting.voyageTeamId; + } + + if (user.roles.includes("admin")) return; + + if (!user.roles.includes("voyager")) { + throw new ForbiddenException( + "Invalid user role for Sprint Meeting access control", + ); + } + + if (!voyagerTeamIds.includes(meetingOrAgendaTeamId!)) { + throw new ForbiddenException( + "Sprint Meeting access control: You can only manage your own project features.", + ); + } +}; diff --git a/src/ability/prisma-generated-types.ts b/src/ability/prisma-generated-types.ts index e8519221..b40cdda2 100644 --- a/src/ability/prisma-generated-types.ts +++ b/src/ability/prisma-generated-types.ts @@ -9,6 +9,9 @@ import { TeamTechStackItem, TeamResource, ProjectFeature, + Sprint, + TeamMeeting, + Agenda, } from "@prisma/client"; export type PrismaSubjects = Subjects<{ @@ -20,4 +23,6 @@ export type PrismaSubjects = Subjects<{ TeamTechStackItem: TeamTechStackItem; Resource: TeamResource; Feature: ProjectFeature; + Sprint: Sprint; + SprintMeetingOrAgenda: TeamMeeting | Agenda; }>; diff --git a/src/sprints/sprints.controller.ts b/src/sprints/sprints.controller.ts index 5e7fb30d..59bf026d 100644 --- a/src/sprints/sprints.controller.ts +++ b/src/sprints/sprints.controller.ts @@ -39,6 +39,7 @@ import { import { BadRequestErrorResponse, ConflictErrorResponse, + ForbiddenErrorResponse, NotFoundErrorResponse, UnauthorizedErrorResponse, } from "../global/responses/errors"; @@ -56,9 +57,11 @@ export class SprintsController { constructor(private readonly sprintsService: SprintsService) {} // dev and admin purpose + @CheckAbilities({ action: Action.Manage, subject: "all" }) @Get() @ApiOperation({ - summary: "gets all the voyages and sprints details in the database", + summary: + "[Roles:Admin] gets all the voyages and sprints details in the database", }) @ApiResponse({ status: HttpStatus.OK, @@ -68,16 +71,21 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) getVoyagesAndSprints() { return this.sprintsService.getVoyagesAndSprints(); } - @Get("teams/:teamId") @ApiOperation({ - summary: "gets all the voyages and sprints given a teamId", + summary: + "[Permissions: Own Team] gets all the voyages and sprints given a teamId", description: "returns all the sprint dates of a particular team", }) @ApiResponse({ @@ -92,24 +100,30 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "teamId", description: "Voyage team Id", required: true, example: 1, }) - getSprintDatesByTeamId(@Param("teamId", ParseIntPipe) teamId: number) { - return this.sprintsService.getSprintDatesByTeamId(teamId); + @CheckAbilities({ action: Action.Read, subject: "Sprint" }) + @Get("teams/:teamId") + getSprintDatesByTeamId( + @Request() req: CustomRequest, + @Param("teamId", ParseIntPipe) teamId: number, + ) { + return this.sprintsService.getSprintDatesByTeamId(teamId, req); } - - // TODO: this route and most routes here will only be available to team member - // To be added with authorization - @Get("meetings/:meetingId") @ApiOperation({ - summary: "gets meeting detail given meeting ID", + summary: "[Permissions: Own Team] gets meeting detail given meeting ID", description: "returns meeting details such as title, meeting time, meeting link, notes, agenda, meeting forms. Everything needed to populate the meeting page.", }) @@ -125,22 +139,32 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, description: "voyage team Meeting ID (TeamMeeting/id)", example: 1, }) - getMeetingById(@Param("meetingId", ParseIntPipe) meetingId: number) { - return this.sprintsService.getMeetingById(meetingId); + @CheckAbilities({ action: Action.Read, subject: "SprintMeetingOrAgenda" }) + @Get("meetings/:meetingId") + getMeetingById( + @Request() req: CustomRequest, + @Param("meetingId", ParseIntPipe) meetingId: number, + ) { + return this.sprintsService.getMeetingById(meetingId, req); } - @Post(":sprintNumber/teams/:teamId/meetings") @ApiOperation({ - summary: "Creates a sprint meeting given a sprint number and team Id", + summary: + "[Permissions: Own Team] Creates a sprint meeting given a sprint number and team Id", description: "Returns meeting details", }) @ApiResponse({ @@ -167,9 +191,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "sprintNumber", required: true, @@ -182,7 +211,10 @@ export class SprintsController { description: "voyage team ID", example: 1, }) + @CheckAbilities({ action: Action.Create, subject: "SprintMeetingOrAgenda" }) + @Post(":sprintNumber/teams/:teamId/meetings") createTeamMeeting( + @Request() req: CustomRequest, @Param("sprintNumber", ParseIntPipe) sprintNumber: number, @Param("teamId", ParseIntPipe) teamId: number, @Body(ValidationPipe) createTeamMeetingDto: CreateTeamMeetingDto, @@ -191,12 +223,12 @@ export class SprintsController { teamId, sprintNumber, createTeamMeetingDto, + req, ); } - @Patch("meetings/:meetingId") @ApiOperation({ - summary: "Updates a meeting given a meeting ID", + summary: "[Permissions: Own Team] Updates a meeting given a meeting ID", description: "Updates meeting detail, including link, time, notes", }) @ApiResponse({ @@ -211,28 +243,36 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, description: "voyage team meeting ID", example: 1, }) + @CheckAbilities({ action: Action.Update, subject: "SprintMeetingOrAgenda" }) + @Patch("meetings/:meetingId") updateTeamMeeting( + @Request() req: CustomRequest, @Param("meetingId", ParseIntPipe) meetingId: number, @Body(ValidationPipe) updateTeamMeetingDto: UpdateTeamMeetingDto, ) { return this.sprintsService.updateTeamMeeting( meetingId, updateTeamMeetingDto, + req, ); } - @Post("meetings/:meetingId/agendas") @ApiOperation({ - summary: "Adds an agenda item given meeting ID", + summary: "[Permissions: Own Team] Adds an agenda item given meeting ID", description: "returns agenda item details.", }) @ApiResponse({ @@ -247,28 +287,37 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, description: "voyage team meeting ID", example: 1, }) + @CheckAbilities({ action: Action.Create, subject: "SprintMeetingOrAgenda" }) + @Post("meetings/:meetingId/agendas") addMeetingAgenda( + @Request() req: CustomRequest, @Param("meetingId", ParseIntPipe) meetingId: number, @Body(ValidationPipe) createAgendaDto: CreateAgendaDto, ) { return this.sprintsService.createMeetingAgenda( meetingId, createAgendaDto, + req, ); } - @Patch("agendas/:agendaId") @ApiOperation({ - summary: "Updates an agenda item given an agenda ID", + summary: + "[Permissions: Own Team] Updates an agenda item given an agenda ID", description: "returns updated agenda item details.", }) @ApiResponse({ @@ -283,28 +332,37 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "agendaId", required: true, description: "agenda ID", example: 1, }) + @CheckAbilities({ action: Action.Update, subject: "SprintMeetingOrAgenda" }) + @Patch("agendas/:agendaId") updateMeetingAgenda( + @Request() req: CustomRequest, @Param("agendaId", ParseIntPipe) agendaId: number, @Body(ValidationPipe) updateAgendaDto: UpdateAgendaDto, ) { return this.sprintsService.updateMeetingAgenda( agendaId, updateAgendaDto, + req, ); } - @Delete("agendas/:agendaId") @ApiOperation({ - summary: "Deletes an agenda item given agenda ID", + summary: + "[Permissions: Own Team] Deletes an agenda item given agenda ID", description: "returns deleted agenda item detail.", }) @ApiResponse({ @@ -319,23 +377,32 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "agendaId", required: true, description: "agenda ID", example: 1, }) - deleteMeetingAgenda(@Param("agendaId", ParseIntPipe) agendaId: number) { - return this.sprintsService.deleteMeetingAgenda(agendaId); + @CheckAbilities({ action: Action.Delete, subject: "SprintMeetingOrAgenda" }) + @Delete("agendas/:agendaId") + deleteMeetingAgenda( + @Request() req: CustomRequest, + @Param("agendaId", ParseIntPipe) agendaId: number, + ) { + return this.sprintsService.deleteMeetingAgenda(agendaId, req); } - @Post("meetings/:meetingId/forms/:formId") @ApiOperation({ summary: - "Adds sprint reviews or sprint planning section to the meeting", + "[Permissions: Own Team] Adds sprint reviews or sprint planning section to the meeting", description: "This creates a record which stores all the responses for this particular forms
" + 'This should only work if the form type is "meeting"
' + @@ -360,9 +427,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, @@ -375,16 +447,23 @@ export class SprintsController { description: "form ID", example: 1, }) + @CheckAbilities({ action: Action.ManageSprintForms, subject: "Sprint" }) + @Post("meetings/:meetingId/forms/:formId") addMeetingFormResponse( + @Request() req: CustomRequest, @Param("meetingId", ParseIntPipe) meetingId: number, @Param("formId", ParseIntPipe) formId: number, ) { - return this.sprintsService.addMeetingFormResponse(meetingId, formId); + return this.sprintsService.addMeetingFormResponse( + meetingId, + formId, + req, + ); } - @Get("meetings/:meetingId/forms/:formId") @ApiOperation({ - summary: "Gets a form given meeting ID and formId", + summary: + "[Permissions: Own Team] Gets a form given meeting ID and formId", description: "returns the form, including questions and responses", }) @ApiResponse({ @@ -404,9 +483,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, @@ -419,6 +503,8 @@ export class SprintsController { description: "form ID", example: 1, }) + @CheckAbilities({ action: Action.ManageSprintForms, subject: "Sprint" }) + @Get("meetings/:meetingId/forms/:formId") getMeetingFormQuestionsWithResponses( @Param("meetingId", ParseIntPipe) meetingId: number, @Param("formId", ParseIntPipe) formId: number, @@ -431,9 +517,9 @@ export class SprintsController { ); } - @Patch("meetings/:meetingId/forms/:formId") @ApiOperation({ - summary: "Updates a form given meeting ID and formId", + summary: + "[Permissions: Own Team] Updates a form given meeting ID and formId", description: "Returns the updated form, including questions and responses
" + "A sample body
" + @@ -466,9 +552,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiParam({ name: "meetingId", required: true, @@ -481,7 +572,10 @@ export class SprintsController { description: "form ID", example: 1, }) + @CheckAbilities({ action: Action.ManageSprintForms, subject: "Sprint" }) + @Patch("meetings/:meetingId/forms/:formId") updateMeetingFormResponse( + @Request() req: CustomRequest, @Param("meetingId", ParseIntPipe) meetingId: number, @Param("formId", ParseIntPipe) formId: number, @Body(new FormInputValidationPipe()) @@ -491,12 +585,14 @@ export class SprintsController { meetingId, formId, updateMeetingFormResponse, + req, ); } + @CheckAbilities({ action: Action.ManageSprintForms, subject: "Sprint" }) @Post("check-in") @ApiOperation({ - summary: "Submit end of sprint check in form", + summary: "[Permissions: Own Team] Submit end of sprint check in form", description: "Inputs (choiceId, text, boolean, number are all optional),
" + "depends on the question type, but AT LEAST ONE of them must be present,
" + @@ -537,9 +633,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiResponse({ status: HttpStatus.CONFLICT, description: "User has already submitted a check in for that sprint.", @@ -577,9 +678,14 @@ export class SprintsController { }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, - description: "User is not logged in or doesn't have admin access", + description: "unauthorized access - user is not logged in", type: UnauthorizedErrorResponse, }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) @ApiQuery({ name: "teamId", required: false, diff --git a/src/sprints/sprints.service.ts b/src/sprints/sprints.service.ts index 9bd9dadf..828bd4a8 100644 --- a/src/sprints/sprints.service.ts +++ b/src/sprints/sprints.service.ts @@ -16,6 +16,8 @@ import { GlobalService } from "../global/global.service"; import { FormTitles } from "../global/constants/formTitles"; import { CustomRequest } from "../global/types/CustomRequest"; import { CheckinQueryDto } from "./dto/get-checkin-form-response"; +import { manageOwnVoyageTeamWithIdParam } from "../ability/conditions/voyage-teams.ability"; +import { manageOwnTeamMeetingOrAgendaById } from "../ability/conditions/meetingOrAgenda.ability"; @Injectable() export class SprintsService { @@ -26,7 +28,7 @@ export class SprintsService { ) {} // this checks if the form with the given formId is of formType = "meeting" - private isMeetingForm = async (formId) => { + private isMeetingForm = async (formId: number) => { const form = await this.prisma.form.findUnique({ where: { id: formId, @@ -100,7 +102,8 @@ export class SprintsService { }); } - async getSprintDatesByTeamId(teamId: number) { + async getSprintDatesByTeamId(teamId: number, req: CustomRequest) { + manageOwnVoyageTeamWithIdParam(req.user, teamId); const teamSprintDates = await this.prisma.voyageTeam.findUnique({ where: { id: teamId, @@ -142,13 +145,14 @@ export class SprintsService { return teamSprintDates.voyage; } - async getMeetingById(meetingId: number) { + async getMeetingById(meetingId: number, req: CustomRequest) { const teamMeeting = await this.prisma.teamMeeting.findUnique({ where: { id: meetingId, }, select: { id: true, + voyageTeamId: true, sprint: { select: { id: true, @@ -213,6 +217,7 @@ export class SprintsService { throw new NotFoundException( `Meeting with id ${meetingId} not found`, ); + manageOwnVoyageTeamWithIdParam(req.user, teamMeeting.voyageTeamId); return teamMeeting; } @@ -226,6 +231,7 @@ export class SprintsService { dateTime, notes, }: CreateTeamMeetingDto, + req: CustomRequest, ) { const sprintId = await this.findSprintIdBySprintNumber( teamId, @@ -237,6 +243,8 @@ export class SprintsService { ); } + manageOwnVoyageTeamWithIdParam(req.user, teamId); + // check if the sprint already has a meeting. // This is temporary just remove this block when the app supports multiple meeting per sprint const isMeetingExist = await this.prisma.teamMeeting.findFirst({ @@ -263,7 +271,6 @@ export class SprintsService { }, }); } - async updateTeamMeeting( meetingId: number, { @@ -273,91 +280,88 @@ export class SprintsService { dateTime, notes, }: UpdateTeamMeetingDto, + req: CustomRequest, ) { - try { - const updatedMeeting = await this.prisma.teamMeeting.update({ - where: { - id: meetingId, - }, - data: { - title, - description, - meetingLink, - dateTime, - notes, - }, - }); - return updatedMeeting; - } catch (e) { - if (e.code === "P2025") { - throw new NotFoundException(`Invalid meetingId: ${meetingId}`); - } - } + await manageOwnTeamMeetingOrAgendaById({ user: req.user, meetingId }); + + return this.prisma.teamMeeting.update({ + where: { + id: meetingId, + }, + data: { + title, + description, + meetingLink, + dateTime, + notes, + }, + }); } async createMeetingAgenda( meetingId: number, { title, description, status }: CreateAgendaDto, + req: CustomRequest, ) { - try { - const newAgenda = await this.prisma.agenda.create({ - data: { - teamMeetingId: meetingId, - title, - description, - status, - }, - }); - return newAgenda; - } catch (e) { - if (e.code === "P2003") { - throw new BadRequestException( - `Invalid meetingId: ${meetingId}`, - ); - } - } + await manageOwnTeamMeetingOrAgendaById({ user: req.user, meetingId }); + + return this.prisma.agenda.create({ + data: { + teamMeetingId: meetingId, + title, + description, + status, + }, + }); } async updateMeetingAgenda( agendaId: number, { title, description, status }: UpdateAgendaDto, + req: CustomRequest, ) { - try { - const updatedMeeting = await this.prisma.agenda.update({ - where: { - id: agendaId, - }, - data: { - title, - description, - status, - }, - }); - return updatedMeeting; - } catch (e) { - if (e.code === "P2025") { - throw new NotFoundException(`Invalid agendaId: ${agendaId}`); - } - } + await manageOwnTeamMeetingOrAgendaById({ user: req.user, agendaId }); + return this.prisma.agenda.update({ + where: { + id: agendaId, + }, + data: { + title, + description, + status, + }, + }); } - async deleteMeetingAgenda(agendaId: number) { - try { - return await this.prisma.agenda.delete({ - where: { - id: agendaId, - }, - }); - } catch (e) { - if (e.code === "P2025") { - throw new NotFoundException( - `${e.meta.cause} agendaId: ${agendaId}`, - ); - } - } + async deleteMeetingAgenda(agendaId: number, req: CustomRequest) { + await manageOwnTeamMeetingOrAgendaById({ user: req.user, agendaId }); + + return this.prisma.agenda.delete({ + where: { + id: agendaId, + }, + }); } - async addMeetingFormResponse(meetingId: number, formId: number) { + async addMeetingFormResponse( + meetingId: number, + formId: number, + req: CustomRequest, + ) { + const meeting = await this.prisma.teamMeeting.findUnique({ + where: { + id: meetingId, + }, + select: { + voyageTeamId: true, + }, + }); + if (!meeting) { + throw new NotFoundException( + `Meeting with Id ${meetingId} does not exist.`, + ); + } + manageOwnVoyageTeamWithIdParam(req.user, meeting.voyageTeamId); if (await this.isMeetingForm(formId)) { try { const formResponseMeeting = @@ -366,6 +370,14 @@ export class SprintsService { formId, meetingId, }, + select: { + id: true, + meeting: { + select: { + voyageTeamId: true, + }, + }, + }, }); const updatedFormResponse = await this.prisma.formResponseMeeting.update({ @@ -391,12 +403,9 @@ export class SprintsService { `FormId: ${formId} does not exist.`, ); } - if (e.meta["field_name"].includes("meetingId")) { - throw new BadRequestException( - `MeetingId: ${meetingId} does not exist.`, - ); - } } + + throw e; } } } @@ -410,6 +419,9 @@ export class SprintsService { where: { id: meetingId, }, + select: { + voyageTeamId: true, + }, }); if (!meeting) @@ -417,6 +429,8 @@ export class SprintsService { `Meeting with Id ${meetingId} does not exist.`, ); + manageOwnVoyageTeamWithIdParam(req.user, meeting.voyageTeamId); + const formResponseMeeting = await this.prisma.formResponseMeeting.findUnique({ where: { @@ -491,7 +505,22 @@ export class SprintsService { meetingId: number, formId: number, responses: UpdateMeetingFormResponseDto, + req: CustomRequest, ) { + const meeting = await this.prisma.teamMeeting.findUnique({ + where: { + id: meetingId, + }, + select: { + voyageTeamId: true, + }, + }); + if (!meeting) { + throw new NotFoundException( + `Meeting with Id ${meetingId} does not exist.`, + ); + } + manageOwnVoyageTeamWithIdParam(req.user, meeting.voyageTeamId); // at this stage, it is unclear what id the frontend is able to send, // if they are able to send the fromResponseMeeting ID, then we won't need this step const formResponseMeeting = diff --git a/test/sprints.e2e-spec.ts b/test/sprints.e2e-spec.ts index 915095af..6506a6f3 100644 --- a/test/sprints.e2e-spec.ts +++ b/test/sprints.e2e-spec.ts @@ -9,13 +9,13 @@ import { CreateAgendaDto } from "src/sprints/dto/create-agenda.dto"; import { toBeOneOf } from "jest-extended"; import * as cookieParser from "cookie-parser"; import { FormTitles } from "../src/global/constants/formTitles"; +import { CASLForbiddenExceptionFilter } from "../src/exception-filters/casl-forbidden-exception.filter"; expect.extend({ toBeOneOf }); describe("Sprints Controller (e2e)", () => { let app: INestApplication; let prisma: PrismaService; - let accessToken: any; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -26,6 +26,7 @@ describe("Sprints Controller (e2e)", () => { app = moduleFixture.createNestApplication(); prisma = moduleFixture.get(PrismaService); app.useGlobalPipes(new ValidationPipe({ transform: true })); + app.useGlobalFilters(new CASLForbiddenExceptionFilter()); app.use(cookieParser()); await app.init(); }); @@ -35,21 +36,16 @@ describe("Sprints Controller (e2e)", () => { await app.close(); }); - beforeEach(async () => { - await loginAndGetTokens( - "jessica.williamson@gmail.com", - "password", - app, - ).then((tokens) => { - accessToken = tokens.access_token; - }); - }); - describe("GET /voyages/sprints - gets all voyage and sprints data", () => { it("should return 200 if fetching all voyage and sprints data", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); return request(app.getHttpServer()) .get(`/voyages/sprints`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -90,16 +86,44 @@ describe("Sprints Controller (e2e)", () => { it("should return 401 if user is not logged in", async () => { return request(app.getHttpServer()) .get(`/voyages/sprints`) + .set("Authorization", `${undefined}`) .expect(401); }); + it("should return 403 if a non-admin user tries to access it", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "leo.rowe@outlook.com", + "password", + app, + ); + return request(app.getHttpServer()) + .get(`/voyages/sprints`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a non-voyager tries to access it", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + return request(app.getHttpServer()) + .get(`/voyages/sprints`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); }); describe("GET /voyages/sprints/teams/:teamId - gets a team's sprint dates", () => { it("should return 200 if fetching all the sprint dates of a particular team was successful", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/teams/${teamId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -129,10 +153,15 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if teamId is invalid", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/teams/${teamId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(404); }); @@ -140,16 +169,46 @@ describe("Sprints Controller (e2e)", () => { const teamId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/teams/${teamId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + it("should return 403 if a user of other team tries to access", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const teamId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/teams/${teamId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a non-voyager tries to access it", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const teamId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/teams/${teamId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); }); describe("GET /voyages/sprints/meetings/:meetingId - gets details for one meeting", () => { it("should return 200 if fetching meeting details was successful", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -228,10 +287,15 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if meetingId is invalid", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(404); }); @@ -239,16 +303,46 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + it("should return 403 if a non-voyager tries to access it", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/meetings/${meetingId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a user of other team tries to access the meeting", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/meetings/${meetingId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); }); describe("PATCH /voyages/sprints/meetings/:meetingId - updates details for a meeting", () => { it("should return 200 if meeting details was successfully updated", async () => { const meetingId = 1; + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: "Test title", dateTime: "2024-02-29T17:17:50.100Z", @@ -288,19 +382,62 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + it("should return 403 if a non-voyager tries to access it", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a user of other team tries to update the meeting", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + + it("should return 404 if meetingId is invalid", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); + const meetingId = 9999; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(404); + }); }); describe("POST /voyages/sprints/:sprintNumber/teams/:teamId/meetings - creates new meeting for a sprint", () => { it("should return 201 if creating sprint meeting details was successful", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 1; const sprintNumber = 4; return request(app.getHttpServer()) .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: FormTitles.sprintPlanning, description: "This is a meeting description.", @@ -338,13 +475,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 409 if trying to create a meeting that already exists for sprint", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 1; const sprintNumber = 4; return request(app.getHttpServer()) .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: FormTitles.sprintPlanning, description: "This is a meeting description.", @@ -356,13 +498,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if teamId not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 999; const sprintNumber = 5; return request(app.getHttpServer()) .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: FormTitles.sprintPlanning, description: "This is a meeting description.", @@ -374,13 +521,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 400 for bad request (title is Number)", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const teamId = 1; const sprintNumber = 5; return request(app.getHttpServer()) .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: 1, //bad request - title should be string dateTime: "2024-03-01T23:11:20.271Z", @@ -392,17 +544,61 @@ describe("Sprints Controller (e2e)", () => { it("should return 401 if user is not logged in", async () => { const teamId = 1; - const sprintNumber = 5; + const sprintNumber = 4; return request(app.getHttpServer()) .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to create a sprint meeting", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const teamId = 1; + const sprintNumber = 4; + return request(app.getHttpServer()) + .post( + `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, + ) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a user of other team tries to create the meetings", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const teamId = 1; + const sprintNumber = 4; + return request(app.getHttpServer()) + .post( + `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, + ) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: FormTitles.sprintPlanning, + description: "This is a meeting description.", + dateTime: "2024-03-01T23:11:20.271Z", + meetingLink: "samplelink.com/meeting1234", + notes: "Notes for the meeting", + }) + .expect(403); + }); }); describe("POST /voyages/sprints/meetings/:meetingId/agendas - creates a new meeting agenda", () => { it("should return 201 if create new agenda was successful", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; const createAgendaDto: CreateAgendaDto = { title: "Test agenda 3", @@ -411,7 +607,7 @@ describe("Sprints Controller (e2e)", () => { }; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/agendas`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send(createAgendaDto) .expect(201) .expect((res) => { @@ -440,12 +636,48 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 400 if meetingId is String", async () => { - const meetingId = " "; + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); + const meetingId = "a"; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/agendas`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Contribute to the agenda!", + description: + "To get started, click the Add Topic button...", + }) + .expect(400); + }); + it("should return 400 if description is missing", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); + const meetingId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/agendas`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: "Contribute to the agenda!", + }) + .expect(400); + }); + it("should return 400 if title is missing", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/agendas`) + .set("Cookie", [access_token, refresh_token]) + .send({ description: "To get started, click the Add Topic button...", }) @@ -456,16 +688,59 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/agendas`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to create an agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + + const meetingId = 1; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/agendas`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Contribute to the agenda!", + description: + "To get started, click the Add Topic button...", + }) + .expect(403); + }); + + it("should return 403 if a user of other team tries to create an agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/agendas`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Contribute to the agenda!", + description: + "To get started, click the Add Topic button...", + }) + .expect(403); + }); }); - describe("PATCH /voyages/sprints/agendas/:agendaId - supdate an agenda", () => { + describe("PATCH /voyages/sprints/agendas/:agendaId - update an agenda", () => { it("should return 200 if updating the agenda was successful with provided values", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const agendaId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/agendas/${agendaId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: "Title updated", description: "New agenda", @@ -498,10 +773,15 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if agendaId is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const agendaId = 9999; return request(app.getHttpServer()) .patch(`/voyages/sprints/agendas/${agendaId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ title: "Title updated", description: "New agenda", @@ -514,15 +794,57 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/agendas/${agendaId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to update an agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const agendaId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/agendas/${agendaId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Title updated", + description: "New agenda", + status: true, + }) + .expect(403); + }); + + it("should return 403 if a user of other team tries to update the agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const agendaId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/agendas/${agendaId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Title updated", + description: "New agenda", + status: true, + }) + .expect(403); + }); }); describe("DELETE /voyages/sprints/agendas/:agendaId - deletes specified agenda", () => { it("should return 200 and delete agenda from database", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const agendaId = 1; return request(app.getHttpServer()) .delete(`/voyages/sprints/agendas/${agendaId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect((res) => { expect(res.body).toEqual( @@ -548,10 +870,15 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if agendaId is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const agendaId = 9999; return request(app.getHttpServer()) .delete(`/voyages/sprints/agendas/${agendaId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(404); }); @@ -559,17 +886,59 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 1; return request(app.getHttpServer()) .delete(`/voyages/sprints/agendas/${agendaId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to delete an agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const agendaId = 1; + return request(app.getHttpServer()) + .delete(`/voyages/sprints/agendas/${agendaId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Title updated", + description: "New agenda", + status: true, + }) + .expect(403); + }); + + it("should return 403 if a user of other team tries to delete the agenda", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const agendaId = 2; + return request(app.getHttpServer()) + .delete(`/voyages/sprints/agendas/${agendaId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + title: "Title updated", + description: "New agenda", + status: true, + }) + .expect(403); + }); }); describe("POST /voyages/sprints/meetings/:meetingId/forms/:formId - creates new meeting form", () => { it("should return 200 and create new meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 2; const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(201) .expect((res) => { expect(res.body).toEqual( @@ -596,29 +965,44 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 409 if form already exists for this meeting", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 2; const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(409); }); - it("should return 400 if meetingId is not found", async () => { + it("should return 404 if meetingId is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 9999; const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) - .expect(400); + .set("Cookie", [access_token, refresh_token]) + .expect(404); }); it("should return 400 if formId is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; const formId = 999; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(400); }); @@ -627,16 +1011,49 @@ describe("Sprints Controller (e2e)", () => { const formId = 999; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to create a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a user of other team tries to create a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); }); describe("GET /voyages/sprints/meetings/:meetingId/forms/:formId - gets meeting form", () => { it("should return 200 if the meeting form was successfully fetched #with responses", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 2; const formId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect((res) => { expect(res.body).toEqual( @@ -670,20 +1087,30 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if meetingId is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 9999; const formId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(404); }); it("should return 400 if formId is is not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 2; const formId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(400); }); @@ -692,17 +1119,50 @@ describe("Sprints Controller (e2e)", () => { const formId = 999; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to create a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); + it("should return 403 if a user of other team tries to create a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .expect(403); + }); }); describe("PATCH /voyages/sprints/meetings/:meetingId/forms/:formId - updates a meeting form", () => { it("should return 200 if successfully create a meeting form response", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; const formId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ responses: [ { @@ -738,11 +1198,16 @@ describe("Sprints Controller (e2e)", () => { return expect(response[0].questionId).toEqual(1); }); it("should return 200 if successfully update a meeting form response", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; const formId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ responses: [ { @@ -779,11 +1244,16 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 400 if formId is a string", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 2; const formId = "Bad request"; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ responses: [ { @@ -798,12 +1268,42 @@ describe("Sprints Controller (e2e)", () => { .expect(400); }); + it("should return 404 if meeting id not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); + const meetingId = 99999; + const formId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + responses: [ + { + questionId: 1, + optionChoiceId: 1, + text: "Team member x landed a job this week.", + boolean: true, + number: 1, + }, + ], + }) + .expect(404); + }); + it("should return 400 if responses in the body is not an array", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const meetingId = 1; const formId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ responses: { questionId: 1, @@ -821,8 +1321,53 @@ describe("Sprints Controller (e2e)", () => { const formId = 999; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Authorization", `${undefined}`) .expect(401); }); + + it("should return 403 if a non-voyager tries to update a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "not_in_voyage@example.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + responses: [ + { + questionId: 1, + text: "Team member x landed a job this week.", + }, + ], + }) + .expect(403); + }); + + it("should return 403 if a user of other team tries to update a meeting form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "JosoMadar@dayrep.com", + "password", + app, + ); + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", [access_token, refresh_token]) + .send({ + responses: [ + { + questionId: 1, + text: "Team member x landed a job this week.", + }, + ], + }) + .expect(403); + }); }); describe("POST /voyages/sprints/check-in - submit sprint check in form", () => { @@ -847,13 +1392,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 201 if successfully submitted a check in form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const responsesBefore = await prisma.response.count(); const responseGroupBefore = await prisma.responseGroup.count(); const checkinsBefore = await prisma.formResponseCheckin.count(); await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 4, // voyageTeamMemberId 1 is already in the seed sprintId: 1, @@ -887,13 +1437,18 @@ describe("Sprints Controller (e2e)", () => { expect(checkinsAfter).toEqual(checkinsBefore + 1); }); it("should return 400 for invalid inputs", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const responsesBefore = await prisma.response.count(); const responseGroupBefore = await prisma.responseGroup.count(); const checkinsBefore = await prisma.formResponseCheckin.count(); // missing voyageTeamMemberId await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ sprintId: 1, responses: [ @@ -908,7 +1463,7 @@ describe("Sprints Controller (e2e)", () => { // missing sprintId" await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -923,7 +1478,7 @@ describe("Sprints Controller (e2e)", () => { // missing responses await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, sprintId: 1, @@ -933,7 +1488,7 @@ describe("Sprints Controller (e2e)", () => { // missing questionId in responses - response validation pipe await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -947,7 +1502,7 @@ describe("Sprints Controller (e2e)", () => { // missing input in responses - response validation pipe await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -961,7 +1516,7 @@ describe("Sprints Controller (e2e)", () => { // wrong response input types await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -975,7 +1530,7 @@ describe("Sprints Controller (e2e)", () => { await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -989,7 +1544,7 @@ describe("Sprints Controller (e2e)", () => { await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -1003,7 +1558,7 @@ describe("Sprints Controller (e2e)", () => { await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 1, responses: [ @@ -1041,9 +1596,14 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 409 if user has already submitted the check in form for the same sprint", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 4, sprintId: 1, @@ -1060,7 +1620,7 @@ describe("Sprints Controller (e2e)", () => { await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 4, sprintId: 1, @@ -1082,9 +1642,14 @@ describe("Sprints Controller (e2e)", () => { expect(checkinsAfter).toEqual(checkinsBefore); }); it("should return 400 if the user doesnot belong to the voyage team", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); await request(app.getHttpServer()) .post(sprintCheckinUrl) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .send({ voyageTeamMemberId: 5, sprintId: 1, @@ -1169,24 +1734,19 @@ describe("Sprints Controller (e2e)", () => { responseGroup: expect.objectContaining(responseGroupShape), }; - beforeEach(async () => { - await loginAndGetTokens( + it("should return 200 if voyageNumber key's value successfully returns a check in form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( "jessica.williamson@gmail.com", "password", app, - ).then((tokens) => { - accessToken = tokens.access_token; - }); - }); - - it("should return 200 if voyageNumber key's value successfully returns a check in form", async () => { + ); const key = "voyageNumber"; const val = "46"; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -1199,13 +1759,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 200 if teamId key's value successfully returns a check in form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const key = "teamId"; const val = "1"; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -1218,13 +1783,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 200 if sprintNumber key's value successfully returns a check in form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const key = ["sprintNumber", "voyageNumber"]; const val = [1, "46"]; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key[0]]: val[0], [key[1]]: val[1] }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -1237,6 +1807,11 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 200 if userId key's value successfully returns a check in form", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const key = "userId"; const user = await prisma.voyageTeamMember.findFirst({ where: { @@ -1253,7 +1828,7 @@ describe("Sprints Controller (e2e)", () => { return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -1266,12 +1841,17 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 400 if query params are invalid", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const key = "teamsId"; const val = "1"; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(400); }); @@ -1282,13 +1862,18 @@ describe("Sprints Controller (e2e)", () => { }); it("should return an empty array if check in form not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); // TODO: create user with no check ins const key = "teamId"; const val = "5"; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(200) .expect((res) => { expect(res.body).toEqual(expect.arrayContaining([])); @@ -1296,12 +1881,17 @@ describe("Sprints Controller (e2e)", () => { }); it("should return 404 if query not found", async () => { + const { access_token, refresh_token } = await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ); const key = "teamId"; const val = "9999"; return request(app.getHttpServer()) .get(sprintCheckinUrl) .query({ [key]: val }) - .set("Cookie", accessToken) + .set("Cookie", [access_token, refresh_token]) .expect(404); }); });