From 59ff18f5b896ca10f994333090f4648059a2df7c Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:11:29 +0000 Subject: [PATCH] feat: implement session deletion --- infra/controller.js | 12 ++ models/session.js | 27 +++- pages/api/v1/sessions/index.js | 11 ++ .../api/v1/sessions/delete.test.js | 144 ++++++++++++++++++ tests/integration/api/v1/user/get.test.js | 4 +- 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 tests/integration/api/v1/sessions/delete.test.js diff --git a/infra/controller.js b/infra/controller.js index 874a9ec..bee5225 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -40,12 +40,24 @@ async function setSessionCookie(sessionToken, response) { response.setHeader("Set-Cookie", setCookie); } +async function clearSessionCookie(response) { + const setCookie = cookie.serialize("session_id", "invalid", { + path: "/", + maxAge: -1, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + }); + + response.setHeader("Set-Cookie", setCookie); +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, onError: onErrorHandler, }, setSessionCookie, + clearSessionCookie, }; export default controller; diff --git a/models/session.js b/models/session.js index 5403425..51e0144 100644 --- a/models/session.js +++ b/models/session.js @@ -28,7 +28,7 @@ async function findOneValidByToken(sessionToken) { if (results.rowCount === 0) { throw new UnauthorizedError({ message: "Usuário não possui sessão ativa.", - action: "Verifique se este usuario está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", }); } @@ -84,11 +84,36 @@ async function renew(sessionId) { } } +async function expireById(sessionId) { + const expiredSessionObject = await runUpdateQuery(sessionId); + return expiredSessionObject; + + async function runUpdateQuery(sessionId) { + const results = await database.query({ + text: ` + UPDATE + sessions + SET + expires_at = expires_at - interval '1 year', + updated_at = NOW() + WHERE + id = $1 + RETURNING + * + ;`, + values: [sessionId], + }); + + return results.rows[0]; + } +} + const session = { create, EXPIRATION_IN_MILLISECONDS, findOneValidByToken, renew, + expireById, }; export default session; diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 519aaaf..5bf7a66 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -7,6 +7,7 @@ import session from "models/session"; const router = createRouter(); router.post(postHandler); +router.delete(deleteHandler); export default router.handler(controller.errorHandlers); @@ -24,3 +25,13 @@ async function postHandler(request, response) { return response.status(201).json(newSession); } + +async function deleteHandler(request, response) { + const sessionToken = request.cookies.session_id; + + const sessionObject = await session.findOneValidByToken(sessionToken); + const expiredSession = await session.expireById(sessionObject.id); + controller.clearSessionCookie(response); + + return response.status(200).json(expiredSession); +} diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js new file mode 100644 index 0000000..4d21fe6 --- /dev/null +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -0,0 +1,144 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import session from "models/session"; +import setCookieParser from "set-cookie-parser"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("DELETE api/v1/session", () => { + describe("Default user", () => { + /*------------------ TESTE 1 ------------------*/ + test("With nonexistent session", async () => { + const nonexistentToken = + "f0b62a5ff97ae607701ceeee2e3c4987c4b9debb534410e2444f9eb2288b6e3b90158a71d086e31eabef9b36cbb549e1"; + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "DELETE", + headers: { + cookie: `session_id=${nonexistentToken}`, + }, + }); + + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + status_code: 401, + }); + }); + + /*------------------ TESTE 2 ------------------*/ + test("With expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), + }); + + const createdUser = await orchestrator.createUser(); + + const sessionObject = await orchestrator.createSession(createdUser.id); + + jest.useRealTimers(); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "DELETE", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + status_code: 401, + }); + }); + + /*------------------ TESTE 3 ------------------*/ + test("With valid session", async () => { + const createdUser = await orchestrator.createUser(); + + const sessionObject = await orchestrator.createSession(createdUser.id); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "DELETE", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: sessionObject.id, + token: sessionObject.token, + user_id: sessionObject.user_id, + expires_at: responseBody.expires_at, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.expires_at)).not.toBeNaN(); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + expect( + responseBody.expires_at < sessionObject.expires_at.toISOString(), + ).toBe(true); + expect( + responseBody.updated_at > sessionObject.updated_at.toISOString(), + ).toBe(true); + + // Set-Cookie assertions + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: "invalid", + maxAge: -1, + path: "/", + httpOnly: true, + }); + + // Double check assertions + const doubleCheckResponse = await fetch( + "http://localhost:3000/api/v1/user", + { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }, + ); + + expect(doubleCheckResponse.status).toBe(401); + + const doubleCheckResponseBody = await doubleCheckResponse.json(); + + expect(doubleCheckResponseBody).toEqual({ + name: "UnauthorizedError", + message: "Usuário não possui sessão ativa.", + action: "Verifique se este usuário está logado e tente novamente.", + status_code: 401, + }); + }); + + /*------------------ TESTE 2 ------------------*/ + }); +}); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 6ff73eb..2cceaf9 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -84,7 +84,7 @@ describe("GET api/v1/user", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se este usuario está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); }); @@ -113,7 +113,7 @@ describe("GET api/v1/user", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se este usuario está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); });