diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 8f66c2b..dbe022c 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "20" - run: npm ci @@ -25,7 +25,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "20" - run: npm ci @@ -41,7 +41,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "20" - run: npm ci diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 182ae91..05e01cb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "20" - run: npm ci diff --git a/infra/controller.js b/infra/controller.js index 68adbf9..874a9ec 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,3 +1,5 @@ +import * as cookie from "cookie"; +import session from "models/session"; import { InternalServerError, MethodNotAllowedError, @@ -27,11 +29,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..5403425 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 este usuario 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,9 +60,35 @@ 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, EXPIRATION_IN_MILLISECONDS, + findOneValidByToken, + renew, }; export default session; diff --git a/models/user.js b/models/user.js index 9f102f1..929f29f 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 username informado não foi encontrado no sistema.", + action: "Verifique se o username está digitado corretamente.", + }); + } + + return results.rows[0]; + } +} + async function findOneByUsername(username) { const userFound = await runSelectQuery(username); return userFound; @@ -184,6 +215,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 294bc4e..519aaaf 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,5 +1,5 @@ import { createRouter } from "next-connect"; -import * as cookie from "cookie"; + import controller from "infra/controller"; import authentication from "models/authentication"; import session from "models/session"; @@ -20,13 +20,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..cb7420f --- /dev/null +++ b/pages/api/v1/user/index.js @@ -0,0 +1,25 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.js"; +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(renewedSessionObject.token, response); + + const userFound = await user.findOneById(sessionObject.user_id); + + response.setHeader("Cache-Control", "no-store, no-cache, 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..6ff73eb --- /dev/null +++ b/tests/integration/api/v1/user/get.test.js @@ -0,0 +1,121 @@ +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("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, 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 = + "b7483614a37a7f37b4f2283cd30613ac7922c534fd2e1932bd4abb251b4bc4915a2f521653f27fb4470624920971508c"; + + 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 este usuario 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 este usuario está logado e tente novamente.", + status_code: 401, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 9307d68..3bfb041 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"; +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;