diff --git a/.gitignore b/.gitignore index d13abc0..627ba0f 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* prisma/generated/ +.vercel diff --git a/src/controller/user.ts b/src/controller/user.ts index cab144e..9693dd1 100644 --- a/src/controller/user.ts +++ b/src/controller/user.ts @@ -43,7 +43,7 @@ export const login = async (req: Request, res: Response) => { return; } const session = await sessionModel.create(user.id); - new CookieResponse(res).setCookie("session", session.id); + response.setCookie("session", session.id); response.created({ message: "Usuário autenticado!" }); } catch (error) { console.error("Erro insperado ao efetuar login: ", error); @@ -51,6 +51,31 @@ export const login = async (req: Request, res: Response) => { } }; +export const logout = async (req: Request, res: Response) => { + const response = new CookieResponse(res); + const sessionId = req.cookies.session; + if (!sessionId) { + response.unauthorized("Faça o login."); + return; + } + try { + response.clearCookie("session"); + const isLogouted = await sessionModel.invalidate(sessionId); + if (!isLogouted) { + response.unauthorized( + "Verifique se está logado e tente novamente.", + "Sessão expirada.", + ); + return; + } + + response.success({ message: "Sessão encerrada!" }); + } catch (error) { + console.error("Erro insperado para realizar o logout: ", error); + response.internalServerError(); + } +}; + export const getUser = async ( req: Request, res: Response, diff --git a/src/models/customResponse.ts b/src/models/customResponse.ts index 7d06c61..df665c0 100644 --- a/src/models/customResponse.ts +++ b/src/models/customResponse.ts @@ -6,7 +6,7 @@ export class CustomResponse { this.response = response; } - errorHandler(error: any) { + private errorHandler(error: any) { if (error.name === "ZodError") { const { issues } = error; return setErrorMessage(issues); diff --git a/src/models/session.ts b/src/models/session.ts index 8b27bd8..c9f8924 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { env } from "~/config/env.js"; import { prisma } from "~/infra/database.js"; import { BaseModel } from "./base.js"; -import { UserDelegate, UserSessionDelegate } from "@/generated/models.js"; +import { UserSessionDelegate } from "@/generated/models.js"; import { userModel, UserModelClass } from "./user.js"; export type AuthenticatedUser = { @@ -30,11 +30,15 @@ class SessionModel extends BaseModel { this.userModel = userModel; } - async create(user_id: string): Promise { - const id = randomUUID(); + private getExpiresAt() { const now = new Date(); const expires_at = new Date(now.getTime() + env.INACTIVITY_TIMEOUT); + return { now, expires_at }; + } + async create(user_id: string): Promise { + const id = randomUUID(); + const { now, expires_at } = this.getExpiresAt(); return await this.createOne({ id, user_id, @@ -43,22 +47,33 @@ class SessionModel extends BaseModel { }); } - async validate(sessionId: string): Promise { + async findOneValid(sessionId: string): Promise { const sessionObject: UserSession = await this.findUnique({ where: { id: sessionId }, }); - if (!sessionObject) return false; - const user = await this.userModel.findOne(sessionObject.user_id); - if (!user) return false; + if (!sessionObject) return false; const now = new Date(); - const lastActive = new Date(String(sessionObject.last_active_at)); + const isExceededTime = now.getTime() > sessionObject.expires_at.getTime(); + + if (isExceededTime) return false; - if (now.getTime() - lastActive.getTime() > env.INACTIVITY_TIMEOUT) - return false; + return sessionObject; + } + + async validate(sessionId: string): Promise { + const sessionObject = await this.findOneValid(sessionId); + if (!sessionObject) return false; - await this.updateOne({ id: sessionId }, { last_active_at: now }); + const { now, expires_at } = this.getExpiresAt(); + await this.updateOne( + { id: sessionId }, + { last_active_at: now, expires_at }, + ); + + const user = await this.userModel.findOne(sessionObject.user_id); + if (!user) return false; const { email, id, name, created_at, updated_at } = user; const secureObjectValue: AuthenticatedUser = { @@ -70,6 +85,14 @@ class SessionModel extends BaseModel { }; return secureObjectValue; } + + async invalidate(sessionId: string) { + const sessionObject = await this.findOneValid(sessionId); + if (!sessionObject) return false; + + await this.updateOne({ id: sessionId }, { expires_at: new Date() }); + return true; + } } export const sessionModel = new SessionModel(prisma.userSession, userModel); diff --git a/src/router/router.ts b/src/router/router.ts index d0923fe..12ca621 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -10,8 +10,9 @@ const router = Router(); router.get("/", index); router.post("/login", userCtrl.login); -router.post("/user", userCtrl.register); router.get("/user", userCtrl.getUser); +router.post("/user", userCtrl.register); +router.delete("/user", userCtrl.logout); router.get("/tasks", authenticate, taskCtrl.getAllTasks); router.get("/tasks/:id", authenticate, taskCtrl.getOneTask); diff --git a/tests/controller/user/delete.test.ts b/tests/controller/user/delete.test.ts new file mode 100644 index 0000000..21cbb95 --- /dev/null +++ b/tests/controller/user/delete.test.ts @@ -0,0 +1,109 @@ +import { API, createSession, createUser } from "$/orchestrator.js"; +import { env } from "~/config/env.js"; + +describe("DELETE `/user`", () => { + describe("Usuário anônimo", () => { + test("Tentando fazer a request", async () => { + const response = await fetch(API.user, { + method: "DELETE", + }); + + expect(response.ok).toBe(false); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + action: "Faça o login.", + message: "Não autorizado.", + }); + }); + }); + + describe("Usuário padrão", () => { + test("Com sessão válida", async () => { + const defaultUser = await createUser(); + const defaultSession = await createSession(defaultUser); + + const response = await fetch(API.user, { + method: "DELETE", + headers: { + Cookie: `session=${defaultSession.id}`, + }, + }); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ message: "Sessão encerrada!" }); + }); + + test("Com sessão quase expirada", async () => { + const horaAtual = new Date(); + const horaSessaoExpirada = new Date( + horaAtual.getTime() - env.INACTIVITY_TIMEOUT / 2, + ); + vi.setSystemTime(horaSessaoExpirada); + + const newUser = await createUser(); + const almostExpiredSession = await createSession(newUser); + + vi.useRealTimers(); + const response = await fetch(API.user, { + method: "DELETE", + headers: { + Cookie: `session=${almostExpiredSession.id}`, + }, + }); + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ message: "Sessão encerrada!" }); + }); + + test("Com sessão expirada", async () => { + const horaAtual = new Date(); + const horaSessaoExpirada = new Date( + horaAtual.getTime() - env.INACTIVITY_TIMEOUT, + ); + vi.setSystemTime(horaSessaoExpirada); + + const newUser = await createUser(); + const expiredSession = await createSession(newUser); + + vi.useRealTimers(); + const response = await fetch(API.user, { + method: "DELETE", + headers: { + Cookie: `session=${expiredSession.id}`, + }, + }); + + expect(response.ok).toBe(false); + expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + action: "Verifique se está logado e tente novamente.", + message: "Sessão expirada.", + }); + + const response2 = await fetch(API.user, { + headers: { + Cookie: `session=${expiredSession.id}`, + }, + }); + + expect(response.ok).toBe(false); + expect(response.status).toBe(401); + + const response2Body = await response2.json(); + expect(response2Body).toEqual({ + action: "Por favor, realize o login novamente.", + message: "Sessão inválida ou expirada.", + }); + }); + }); +});