From 5f77e138ca1164f5346a6f2a4f147cb2fcbe110b Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 18 Aug 2025 05:40:53 +0000 Subject: [PATCH] feat: implement session deletion --- infra/controller.js | 11 ++ models/session.js | 24 ++++ pages/api/v1/sessions/index.js | 11 ++ .../api/v1/sessions/delete.test.js | 134 ++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 test/integration/api/v1/sessions/delete.test.js diff --git a/infra/controller.js b/infra/controller.js index 0ea9043..5bf86cc 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -40,12 +40,23 @@ 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 19c8082..6db1d6d 100644 --- a/models/session.js +++ b/models/session.js @@ -83,11 +83,35 @@ async function renew(sessionId) { } } +async function exporeById(sessionId) { + const expiredSessionObject = await runUpdateQuery(sessionId); + return expiredSessionObject; + + async function runUpdateQuery(sessionId) { + const result = await database.query({ + text: ` + UPDATE + sessions + SET + expires_at = expires_at - interval '1 year', + updated_at = NOW() + WHERE + id = $1 + RETURNING + * + ;`, + values: [sessionId], + }); + return result.rows[0]; + } +} + const session = { create, findOneValidByToken, EXPIRATION_IN_MILISECONS, renew, + exporeById, }; export default session; diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index cc195ff..82bc3d8 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); @@ -22,3 +23,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.exporeById(sessionObject.id); + controller.clearSessionCookie(response); + + return response.status(200).json(expiredSession); +} diff --git a/test/integration/api/v1/sessions/delete.test.js b/test/integration/api/v1/sessions/delete.test.js new file mode 100644 index 0000000..c8090a7 --- /dev/null +++ b/test/integration/api/v1/sessions/delete.test.js @@ -0,0 +1,134 @@ +import { version as uuidVersion } from "uuid"; +import setCookieParser from "set-cookie-parser"; + +import orchestrator from "test/orchestrator"; +import session from "models/session"; + +beforeAll(async () => { + await orchestrator.waitAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("DELETE /api/v1/user", () => { + describe("Default user", () => { + test("With nonexistent session", async () => { + const nonexistentToken = + "244a21939f3a3f0ee61648792da0819d0b0bef15a8909ecf1c096b49ed728833a9ce5c831caa7cf8a977e463616d7db6"; + + const response = await fetch(`http://localhost:3000/api/v1/sessions`, { + method: "DELETE", + headers: { + Cookie: `session_id=${nonexistentToken}`, + }, + }); + + const responseBody = await response.json(); + expect(response.status).toBe(401); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "User do not have a valid session.", + action: "Check if user is logged in and try again.", + status_code: 401, + }); + }); + + test("With expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILISECONS), + }); + + const createdUser = await orchestrator.createUser({ + username: "UserWithExpiredSession", + }); + 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}`, + }, + }); + + const responseBody = await response.json(); + expect(response.status).toBe(401); + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "User do not have a valid session.", + action: "Check if user is logged in and try again.", + status_code: 401, + }); + }); + + test("With valid session", async () => { + const createdUser = await orchestrator.createUser({ + username: "UserWithValidSession", + }); + 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}`, + }, + }); + + const responseBody = await response.json(); + // const caheControl = response.headers.get("Cache-Control"); + + expect(response.status).toBe(200); + // expect(caheControl).toBe( + // "no-store, no-cache, max-age=0, must-revalidate", + // ); + 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 header assertions + const parsedCookie = setCookieParser(response, { map: true }); + expect(parsedCookie.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}`, + }, + }, + ); + + const doubleCheckResponseBody = await doubleCheckResponse.json(); + expect(doubleCheckResponse.status).toBe(401); + expect(doubleCheckResponseBody).toEqual({ + name: "UnauthorizedError", + message: "User do not have a valid session.", + action: "Check if user is logged in and try again.", + status_code: 401, + }); + }); + }); +});