From 453392a4d8fdf30a665bb5caf52d48968abbf7a9 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 8 Aug 2025 01:52:18 +0000 Subject: [PATCH] feat: implement `/api/v1/user` endpoint with session renewal --- infra/controller.js | 15 +++ models/session.js | 61 +++++++++++ models/user.js | 32 ++++++ pages/api/v1/sessions/index.js | 9 +- pages/api/v1/user/index.js | 26 +++++ tests/integration/api/v1/user/get.test.js | 128 ++++++++++++++++++++++ tests/orchestrator.js | 6 + 7 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 pages/api/v1/user/index.js create mode 100644 tests/integration/api/v1/user/get.test.js diff --git a/infra/controller.js b/infra/controller.js index 8d09b9d..a0d1477 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,3 +1,6 @@ +import * as cookie from "cookie"; +import session from "models/session.js"; + import { InternalServerError, MethodNotAllowedError, @@ -30,11 +33,23 @@ function onErrorHandler(error, request, response) { response.status(publicErrorObject.statusCode).json(publicErrorObject); } +async function setSessionCookie(sessionToken, response) { + const setCookie = cookie.serialize("session_id", sessionToken, { + path: "/", + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + }); + + response.setHeader("Set-Cookie", setCookie); +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, onError: onErrorHandler, }, + setSessionCookie, }; export default controller; diff --git a/models/session.js b/models/session.js index 05006c8..dd529e3 100644 --- a/models/session.js +++ b/models/session.js @@ -1,8 +1,41 @@ import crypto from "node:crypto"; import database from "infra/database.js"; +import { UnauthorizedError } from "infra/errors"; const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 Days +async function findOneValidByToken(sessionToken) { + const sessionFound = await runSelectQuery(sessionToken); + + return sessionFound; + + async function runSelectQuery(sessionToken) { + const results = await database.query({ + text: ` + SELECT + * + FROM + sessions + WHERE + token = $1 + AND expires_at > NOW() + LIMIT + 1 + `, + values: [sessionToken], + }); + + if (results.rowCount === 0) { + throw new UnauthorizedError({ + message: "Usuário não possui sessão ativa.", + action: "Verifique se esse usuário está logado e tente novamente.", + }); + } + + return results.rows[0]; + } +} + async function create(userId) { const token = crypto.randomBytes(48).toString("hex"); const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); @@ -27,8 +60,36 @@ async function create(userId) { } } +async function renew(sessionId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const renewedSessionObject = runUpdateQuery(sessionId, expiresAt); + return renewedSessionObject; + + async function runUpdateQuery(sessionId, expiresAt) { + const results = await database.query({ + text: ` + UPDATE + sessions + SET + expires_at = $2, + updated_at = NOW() + WHERE + id = $1 + RETURNING + * + ;`, + values: [sessionId, expiresAt], + }); + + return results.rows[0]; + } +} + const session = { create, + findOneValidByToken, + renew, EXPIRATION_IN_MILLISECONDS, }; diff --git a/models/user.js b/models/user.js index 293aef1..cbb296c 100644 --- a/models/user.js +++ b/models/user.js @@ -2,6 +2,37 @@ import database from "infra/database.js"; import password from "models/password.js"; import { ValidationError, NotFoundError } from "infra/errors.js"; +async function findOneById(id) { + const userFound = await runSelectQuery(id); + + return userFound; + + async function runSelectQuery(id) { + const results = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + id = $1 + LIMIT + 1 + ;`, + values: [id], + }); + + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "O id informado não foi encontrado no sistema.", + action: "Verifique se o id foi digitado corretamente", + }); + } + + return results.rows[0]; + } +} + async function findOneByUsername(username) { const userFound = await runSelectQuery(username); @@ -189,6 +220,7 @@ async function hashPasswordInObject(userInputValues) { const user = { create, + findOneById, findOneByUsername, findOneByEmail, update, diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index fe622e7..06e4384 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,5 +1,4 @@ import { createRouter } from "next-connect"; -import * as cookie from "cookie"; import controller from "infra/controller.js"; import authentication from "models/authentication.js"; import session from "models/session.js"; @@ -20,13 +19,7 @@ async function postHandler(request, response) { const newSession = await session.create(authenticatedUser.id); - const setCookie = cookie.serialize("session_id", newSession.token, { - path: "/", - maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, - secure: process.env.NODE_ENV === "production", - httpOnly: true, - }); - response.setHeader("Set-Cookie", setCookie); + controller.setSessionCookie(newSession.token, response); return response.status(201).json(newSession); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js new file mode 100644 index 0000000..a6c914f --- /dev/null +++ b/pages/api/v1/user/index.js @@ -0,0 +1,26 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import user from "models/user.js"; +import session from "models/session"; + +const router = createRouter(); + +router.get(getHandler); + +export default router.handler(controller.errorHandlers); + +async function getHandler(request, response) { + const sessionToken = request.cookies.session_id; + + const sessionObject = await session.findOneValidByToken(sessionToken); + const renewedSessionObject = await session.renew(sessionObject.id); + controller.setSessionCookie(sessionObject.token, response); + + const userFound = await user.findOneById(renewedSessionObject.user_id); + + response.setHeader( + "Cache-Control", + "no-store, no-cache, max-age=0, must-revalidate", + ); + return response.status(200).json(userFound); +} diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js new file mode 100644 index 0000000..8e9e3d7 --- /dev/null +++ b/tests/integration/api/v1/user/get.test.js @@ -0,0 +1,128 @@ +import { version as uuidVersion } from "uuid"; +import setCookieParser from "set-cookie-parser"; +import orchestrator from "tests/orchestrator.js"; +import session from "models/session.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("GET /api/v1/user", () => { + describe("Default user", () => { + 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/user", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(200); + + const cacheControl = response.headers.get("Cache-Control"); + expect(cacheControl).toBe( + "no-store, no-cache, max-age=0, must-revalidate", + ); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: createdUser.id, + username: "UserWithValidSession", + email: createdUser.email, + password: createdUser.password, + created_at: createdUser.created_at.toISOString(), + updated_at: createdUser.updated_at.toISOString(), + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + // Session renewal assertions + const renewedSessionObject = await session.findOneValidByToken( + sessionObject.token, + ); + + expect( + renewedSessionObject.expires_at > sessionObject.expires_at, + ).toEqual(true); + expect( + renewedSessionObject.updated_at > sessionObject.updated_at, + ).toEqual(true); + + // Set-Cookie assertions + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: sessionObject.token, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + path: "/", + httpOnly: true, + }); + }); + + test("With nonexistent session", async () => { + const nonexistentToken = + "5a22deb74407dcbb628d7ac12f294c2b8d223c56f384118df9e3b585631c3a4989a6cd511b8581a408f7633cace2c651"; + + const response = await fetch("http://localhost:3000/api/v1/user", { + 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 esse usuário está logado e tente novamente.", + status_code: 401, + }); + }); + + test("With expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), + }); + + 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/user", { + 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 esse usuário está logado e tente novamente.", + status_code: 401, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 3968bda..d54d536 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -4,6 +4,7 @@ import { faker } from "@faker-js/faker"; import database from "infra/database.js"; import migrator from "models/migrator.js"; import user from "models/user.js"; +import session from "models/session"; async function waitForAllServices() { await waitForWebServer(); @@ -40,11 +41,16 @@ async function createUser(userObject) { }); } +async function createSession(userId) { + return await session.create(userId); +} + const orchestrator = { waitForAllServices, clearDatabase, runPendingMigrations, createUser, + createSession, }; export default orchestrator;