diff --git a/src/__tests__/helpers/auth.ts b/src/__tests__/helpers/auth.ts new file mode 100644 index 0000000..6748266 --- /dev/null +++ b/src/__tests__/helpers/auth.ts @@ -0,0 +1,12 @@ +import jwt from "jsonwebtoken" +import { makeJWTPayload } from "../../test/index.js" + +export function makeAuthToken( + overrides?: Partial> +) { + const payload = makeJWTPayload(overrides) + + return jwt.sign(payload, process.env.JWT_SECRET as string, { + expiresIn: "1h" + }) +} diff --git a/src/__tests__/helpers/reset-db.ts b/src/__tests__/helpers/reset-db.ts index 62063a7..4f49ddc 100644 --- a/src/__tests__/helpers/reset-db.ts +++ b/src/__tests__/helpers/reset-db.ts @@ -2,9 +2,9 @@ import { prisma } from "../../lib/prisma.js" export async function resetDB() { await prisma.$transaction([ - prisma.user.deleteMany(), prisma.task.deleteMany(), + prisma.teamMember.deleteMany(), prisma.team.deleteMany(), - prisma.teamMember.deleteMany() + prisma.user.deleteMany() ]) } diff --git a/src/__tests__/helpers/seed.ts b/src/__tests__/helpers/seed.ts new file mode 100644 index 0000000..4c9a16a --- /dev/null +++ b/src/__tests__/helpers/seed.ts @@ -0,0 +1,57 @@ +import { prisma } from "../../lib/prisma.js" +import bcrypt from "bcrypt" +import { makeRegisterUserData, makeTask } from "../../test/index.js" +import { SALT_ROUNDS } from "../../services/auth.service.js" +import { makeTeam } from "../../test/factories/team.js" + +export async function seedUser( + overrides: Parameters[0] = {} +) { + const { password, ...rest } = makeRegisterUserData(overrides) + return prisma.user.create({ + data: { + ...rest, + password: await bcrypt.hash(password, SALT_ROUNDS) + } + }) +} + +export async function seedTeam(overrides: Parameters[0] = {}) { + const { id, ...team } = makeTeam() + + return prisma.team.create({ + data: team + }) +} + +export async function seedTask( + assigned_to: string, + teamId: string, + overrides: Parameters[0] = {} +) { + return prisma.task.create({ + data: makeTask({ assigned_to, teamId, ...overrides }) + }) +} + +export async function seedTeamMember(user_id: string, team_id: string) { + return prisma.teamMember.create({ + data: { user_id, team_id } + }) +} + +export async function seedUserWithTeamAndTask( + overrides: { + task?: Parameters[0] + team?: Parameters[0] + user?: Parameters[0] + } = {} +) { + const user = await seedUser(overrides.user) + const team = await seedTeam(overrides.team) + + await seedTeamMember(user.id, team.id) + const task = await seedTask(user.id, team.id, overrides.task) + + return { user, team, task } +} diff --git a/src/__tests__/tasks.integration.test.ts b/src/__tests__/tasks.integration.test.ts new file mode 100644 index 0000000..0de749f --- /dev/null +++ b/src/__tests__/tasks.integration.test.ts @@ -0,0 +1,261 @@ +import app from "../lib/create-server.js" +import request from "supertest" +import { makeAuthToken } from "./helpers/auth.js" +import { + seedTask, + seedTeam, + seedTeamMember, + seedUser, + seedUserWithTeamAndTask +} from "./helpers/seed.js" +import { makeRegisterTask, makeUpdatedTask } from "../test/index.js" + +const PATH_TASKS = "/tasks" + +describe("Integration: Tasks", () => { + it("should return 401 when user is not authenticated", async () => { + const { body } = await request(app).get(PATH_TASKS).expect(401) + expect(body.message).toBe("Token not provided") + }) + + it("should return 401 when token is invalid", async () => { + const { body } = await request(app) + .get(PATH_TASKS) + .expect(401) + .auth("token-invalid", { + type: "bearer" + }) + expect(body.message).toBe("Token invalid") + }) + + describe("when task does not exist", () => { + it.each(["put", "delete"] as const)( + "[%s] should return 400 when task not found", + async method => { + const user = await seedUser({ role: "ADMIN" }) + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + const req = request(app) + [method](`${PATH_TASKS}/no-existing-task-id`) + .auth(token, { type: "bearer" }) + + if (method === "put") { + req.send(makeUpdatedTask()) + } + + const { body } = await req.expect(400) + + expect(body.message).toBe("Task not found") + } + ) + }) + + describe("[GET] /tasks", () => { + it("should return 403 when user role not allowed", async () => { + const token = makeAuthToken({ + role: "MEMBER" + }) + + const { body } = await request(app) + .get(PATH_TASKS) + .auth(token, { + type: "bearer" + }) + .expect(403) + + expect(body.message).toBe("Access denied") + }) + + it("should return 400 when team not found", async () => { + const token = makeAuthToken({ + role: "MEMBER" + }) + + const { body } = await request(app) + .get(`${PATH_TASKS}/no-existing-team-id`) + .auth(token, { + type: "bearer" + }) + .expect(400) + + expect(body.message).toBe("Team not found") + }) + + it("should return all tasks assigned to the user", async () => { + const { user, task, team } = await seedUserWithTeamAndTask() + + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + const { body } = await request(app) + .get(`${PATH_TASKS}/${team.id}`) + .auth(token, { type: "bearer" }) + .expect(200) + + expect(body.tasks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: task.id, title: task.title }) + ]) + ) + }) + }) + + describe("[POST] /tasks", () => { + it("should return 403 when user role not allowed", async () => { + const team = await seedTeam() + const user = await seedUser({ + role: "MEMBER" + }) + + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + const payload = makeRegisterTask() + const { body } = await request(app) + .post(`${PATH_TASKS}/${team.id}/${user.id}`) + .auth(token, { type: "bearer" }) + .send(payload) + .expect(403) + + expect(body.message).toBe("Access denied") + }) + + it("should return 201 with task created", async () => { + const team = await seedTeam() + const user = await seedUser({ + role: "ADMIN" + }) + + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + const payload = makeRegisterTask() + const { body } = await request(app) + .post(`${PATH_TASKS}/${team.id}/${user.id}`) + .auth(token, { type: "bearer" }) + .send(payload) + .expect(201) + + expect(body.task).toEqual( + expect.objectContaining({ + title: payload.title, + description: payload.description, + status: payload.status, + priority: payload.priority, + assigned_to: user.id + }) + ) + }) + }) + + describe("[PUT] /tasks", () => { + it("should not allow a MEMBER to update a task assigned to another user", async () => { + const taskOwner = await seedUser({ role: "MEMBER" }) + const team = await seedTeam() + + await seedTeamMember(taskOwner.id, team.id) + const task = await seedTask(taskOwner.id, team.id, { + status: "PENDING", + priority: "MEDIUM" + }) + + const unauthorizedMember = await seedUser({ + role: "MEMBER", + email: "unauthorized-user@gmail.com" + }) + await seedTeamMember(unauthorizedMember.id, team.id) + + const token = makeAuthToken({ + userId: unauthorizedMember.id, + email: unauthorizedMember.email, + role: unauthorizedMember.role + }) + + const payload = makeUpdatedTask({ + status: "IN_PROGRESS", + priority: "HIGH" + }) + + const { body } = await request(app) + .put(`${PATH_TASKS}/${task.id}`) + .auth(token, { type: "bearer" }) + .send(payload) + .expect(403) + + expect(body.message).toBe( + "You do not have permission to update this task" + ) + }) + + it("should update a task when the user is the assignee", async () => { + const { user, task } = await seedUserWithTeamAndTask({ + task: { + status: "PENDING", + priority: "MEDIUM" + }, + user: { + role: "MEMBER" + } + }) + + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + const payload = makeUpdatedTask({ + status: "IN_PROGRESS", + priority: "HIGH" + }) + + const { body } = await request(app) + .put(`${PATH_TASKS}/${task.id}`) + .auth(token, { type: "bearer" }) + .send(payload) + .expect(200) + + expect(body.task).toEqual( + expect.objectContaining({ + status: "IN_PROGRESS", + priority: "HIGH" + }) + ) + }) + }) + + describe("[DELETE] /tasks", () => { + it("should return 204 when task is deleted successfully", async () => { + const user = await seedUser({ + role: "ADMIN" + }) + + const team = await seedTeam() + const task = await seedTask(user.id, team.id) + + const token = makeAuthToken({ + userId: user.id, + email: user.email, + role: user.role + }) + + await request(app) + .delete(`${PATH_TASKS}/${task.id}`) + .auth(token, { type: "bearer" }) + .expect(204) + }) + }) +}) diff --git a/src/lib/create-server.ts b/src/lib/create-server.ts index 70b734d..342e3d3 100644 --- a/src/lib/create-server.ts +++ b/src/lib/create-server.ts @@ -5,6 +5,12 @@ import { AuthService } from "../services/auth.service.js" import { JWTService } from "../services/jwt.service.js" import { AuthController } from "../controllers/auth.controller.js" import { errorHandler } from "../middlewares/error.handler.middleware.js" +import { authenticate } from "../middlewares/authenticate.middleware.js" +import { authorize } from "../middlewares/authorize.middleware.js" +import { TaskRepository } from "../repositories/task.repository.js" +import { TeamRepository } from "../repositories/team.repository.js" +import { TasksService } from "../services/tasks.service.js" +import { TasksController } from "../controllers/task.controller.js" const app = express() @@ -16,9 +22,44 @@ const authService = new AuthService(authRepository) const jwtService = new JWTService() const authController = new AuthController(authService, jwtService) +const taskRepository = new TaskRepository() +const teamRepository = new TeamRepository() + +const taskService = new TasksService(taskRepository, teamRepository) +const taskController = new TasksController(taskService) + app.post("/auth/register", authController.signUp) app.post("/auth/login", authController.signIn) +app.get("/tasks", authenticate, authorize(["ADMIN"]), taskController.readTasks) +app.get( + "/tasks/:teamId", + authenticate, + authorize(["ADMIN", "MEMBER"]), + taskController.readTasksFromTeam +) + +app.post( + "/tasks/:teamId/:assignedTo", + authenticate, + authorize(["ADMIN"]), + taskController.register +) + +app.put( + "/tasks/:taskId", + authenticate, + authorize(["ADMIN", "MEMBER"]), + taskController.update +) + +app.delete( + "/tasks/:taskId", + authenticate, + authorize(["ADMIN"]), + taskController.remove +) + app.use(errorHandler) export default app diff --git a/src/middlewares/error.handler.middleware.ts b/src/middlewares/error.handler.middleware.ts index 5197a9e..1001b31 100644 --- a/src/middlewares/error.handler.middleware.ts +++ b/src/middlewares/error.handler.middleware.ts @@ -1,6 +1,7 @@ -import { Request, Response, NextFunction } from 'express' -import { ZodError } from 'zod' -import { formatZodError } from '../utils/format-zod-error.js' +import { Request, Response, NextFunction } from "express" +import { ZodError } from "zod" +import { formatZodError } from "../utils/format-zod-error.js" +import { UnauthorizedError } from "../errors/unauthorized-error.js" export function errorHandler( error: Error, @@ -12,9 +13,13 @@ export function errorHandler( return res.status(400).json(formatZodError(error)) } + if (error instanceof UnauthorizedError) { + return res.status(403).json({ message: error.message }) + } + if (error.message) { return res.status(400).json({ message: error.message }) } - return res.status(500).json({ message: 'Internal server error' }) + return res.status(500).json({ message: "Internal server error" }) } diff --git a/src/test/setup-integration.ts b/src/test/setup-integration.ts index 2da0960..e9ca138 100644 --- a/src/test/setup-integration.ts +++ b/src/test/setup-integration.ts @@ -1,4 +1,4 @@ -import { afterEach, afterAll } from "vitest" +import { afterAll } from "vitest" import { prisma } from "../lib/prisma.js" import { resetDB } from "../__tests__/helpers/reset-db.js" @@ -6,7 +6,7 @@ beforeAll(async () => { await prisma.$connect() }) -afterEach(async () => { +beforeEach(async () => { await resetDB() })