From 191ae0534623d4b5a7600da9ccbb810c91f81e4d Mon Sep 17 00:00:00 2001 From: jvgasparoni Date: Wed, 5 Nov 2025 15:21:34 -0300 Subject: [PATCH 1/4] build: install `cookie` and `set-cookie-parser` --- package-lock.json | 20 +++++++++++++++++++- package.json | 4 +++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 18cab8b..b156894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "^1.0.2", "dotenv": "16.4.5", "dotenv-expand": "11.0.6", "next": "14.2.5", @@ -35,7 +36,8 @@ "eslint-plugin-jest": "28.8.0", "husky": "9.1.4", "jest": "29.7.0", - "prettier": "3.3.3" + "prettier": "3.3.3", + "set-cookie-parser": "^2.7.1" } }, "node_modules/@babel/code-frame": { @@ -4048,6 +4050,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cosmiconfig": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", @@ -10550,6 +10561,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index bab881c..78b1564 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dependencies": { "async-retry": "1.3.3", "bcryptjs": "3.0.2", + "cookie": "^1.0.2", "dotenv": "16.4.5", "dotenv-expand": "11.0.6", "next": "14.2.5", @@ -49,7 +50,8 @@ "eslint-plugin-jest": "28.8.0", "husky": "9.1.4", "jest": "29.7.0", - "prettier": "3.3.3" + "prettier": "3.3.3", + "set-cookie-parser": "^2.7.1" }, "config": { "commitizen": { From 959b364c049f01d2705df96e502bbe55acdd9cbf Mon Sep 17 00:00:00 2001 From: jvgasparoni Date: Wed, 5 Nov 2025 15:22:05 -0300 Subject: [PATCH 2/4] feat: implement session creation --- .vscode/settings.json | 72 +++++++-- infra/controller.js | 10 +- infra/errors.js | 26 ++- .../1762199477615_create-sessions.js | 41 +++++ models/authentication.js | 60 +++++++ models/session.js | 35 ++++ models/user.js | 105 ++++++++---- pages/api/v1/sessions/index.js | 32 ++++ .../integration/api/v1/sessions/post.test.js | 152 ++++++++++++++++++ 9 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 infra/migrations/1762199477615_create-sessions.js create mode 100644 models/authentication.js create mode 100644 models/session.js create mode 100644 pages/api/v1/sessions/index.js create mode 100644 tests/integration/api/v1/sessions/post.test.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 55eb32b..ffd84d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,69 @@ { - "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "never" + } + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "never" + } + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "never" + } + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "never" + } + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, "editor.formatOnSave": true, "files.autoSave": "off", "editor.minimap.enabled": false, - "prettier.workingDirectory": "auto", "prettier.useEditorConfig": true, - "prettier.requireConfig": false -} + "prettier.requireConfig": false, + // ⛔️ impede que o editor apague imports ao salvar + "editor.codeActionsOnSave": { + "source.organizeImports": "never", + "source.fixAll.eslint": "explicit" + }, + // (opcional, redundante mas garantido por linguagem) + "[typescript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": false, + "source.fixAll.eslint": false + } + }, + "[typescriptreact]": { + "editor.codeActionsOnSave": { + "source.organizeImports": false, + "source.fixAll.eslint": false + } + }, + "[javascript]": { + "editor.codeActionsOnSave": { + "source.organizeImports": false, + "source.fixAll.eslint": false + } + }, + "[javascriptreact]": { + "editor.codeActionsOnSave": { + "source.organizeImports": false, + "source.fixAll.eslint": false + } + } +} \ No newline at end of file diff --git a/infra/controller.js b/infra/controller.js index 2de25d7..27a8336 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,8 +1,9 @@ import { InternalServerError, MethodNotAllowedError, - NotFoundError, ValidationError, + NotFoundError, + UnauthorizedError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -11,12 +12,15 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { - if (error instanceof ValidationError || error instanceof NotFoundError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof UnauthorizedError + ) { return response.status(error.statusCode).json(error); } const publicErrorObject = new InternalServerError({ - statusCode: error.statusCode, cause: error, }); diff --git a/infra/errors.js b/infra/errors.js index 990b2df..3b3110c 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -37,6 +37,7 @@ export class ServiceError extends Error { }; } } + export class ValidationError extends Error { constructor({ cause, message, action }) { super(message || "Um erro de validação ocorreu.", { @@ -56,13 +57,15 @@ export class ValidationError extends Error { }; } } + export class NotFoundError extends Error { constructor({ cause, message, action }) { - super(message || "Não foi possível encontrar o recurso solicitado.", { + super(message || "Não foi possível encontrar este recurso no sistema.", { cause, }); this.name = "NotFoundError"; - this.action = action || "Verifique se o recurso solicitado existe."; + this.action = + action || "Verifique se os parâmetros enviados na consulta estão certos."; this.statusCode = 404; } @@ -75,6 +78,25 @@ export class NotFoundError extends Error { }; } } +export class UnauthorizedError extends Error { + constructor({ cause, message, action }) { + super(message || "Usuário não autenticado.", { + cause, + }); + this.name = "UnauthorizedError"; + this.action = action || "Faça novamente o login para continuar."; + this.statusCode = 401; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} export class MethodNotAllowedError extends Error { constructor() { diff --git a/infra/migrations/1762199477615_create-sessions.js b/infra/migrations/1762199477615_create-sessions.js new file mode 100644 index 0000000..6f829cc --- /dev/null +++ b/infra/migrations/1762199477615_create-sessions.js @@ -0,0 +1,41 @@ +exports.up = (pgm) => { + pgm.createTable("sessions", { + id: { + type: "uuid", + primaryKey: true, + default: pgm.func("gen_random_uuid()"), + }, + + token: { + type: "varchar(96)", + notNull: true, + unique: true, + }, + + user_id: { + type: "uuid", + notNull: true, + }, + + // Why timestamp with timezone? https://justatheory.com/2012/04/postgres-use-timestamptz/ + expires_at: { + type: "timestamptz", + notNull: true, + }, + + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + + updated_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + }); +}; + +exports.down = false; +exports.down = false; diff --git a/models/authentication.js b/models/authentication.js new file mode 100644 index 0000000..a80b030 --- /dev/null +++ b/models/authentication.js @@ -0,0 +1,60 @@ +import user from "models/user.js"; +import password from "models/password.js"; +import { NotFoundError, UnauthorizedError } from "infra/errors.js"; + +async function getAuthenticatedUser(providedEmail, providedPassword) { + try { + const storedUser = await findUserByEmail(providedEmail); + await validatePassword(providedPassword, storedUser.password); + + return storedUser; + } catch (error) { + if (error instanceof UnauthorizedError) { + throw new UnauthorizedError({ + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + }); + } + + throw error; + } + + async function findUserByEmail(providedEmail) { + let storedUser; + + try { + storedUser = await user.findOneByEmail(providedEmail); + } catch (error) { + if (error instanceof NotFoundError) { + throw new UnauthorizedError({ + message: "Email não confere.", + action: "Verifique se este dado está correto.", + }); + } + + throw error; + } + + return storedUser; + } + + async function validatePassword(providedPassword, storedPassword) { + const correctPasswordMatch = await password.compare( + providedPassword, + storedPassword, + ); + + if (!correctPasswordMatch) { + throw new UnauthorizedError({ + message: "Senha não confere.", + action: "Verifique se este dado está correto.", + }); + } + } +} + +const authentication = { + getAuthenticatedUser, +}; + +export default authentication; diff --git a/models/session.js b/models/session.js new file mode 100644 index 0000000..05006c8 --- /dev/null +++ b/models/session.js @@ -0,0 +1,35 @@ +import crypto from "node:crypto"; +import database from "infra/database.js"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 Days + +async function create(userId) { + const token = crypto.randomBytes(48).toString("hex"); + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newSession = await runInsertQuery(token, userId, expiresAt); + return newSession; + + async function runInsertQuery(token, userId, expiresAt) { + const results = await database.query({ + text: ` + INSERT INTO + sessions (token, user_id, expires_at) + VALUES + ($1, $2, $3) + RETURNING + * + ;`, + values: [token, userId, expiresAt], + }); + + return results.rows[0]; + } +} + +const session = { + create, + EXPIRATION_IN_MILLISECONDS, +}; + +export default session; diff --git a/models/user.js b/models/user.js index c9e17f6..33bac2e 100644 --- a/models/user.js +++ b/models/user.js @@ -1,6 +1,6 @@ import database from "infra/database.js"; -import { NotFoundError, ValidationError } from "infra/errors.js"; import password from "models/password.js"; +import { ValidationError, NotFoundError } from "infra/errors.js"; async function findOneByUsername(username) { const userFound = await runSelectQuery(username); @@ -16,11 +16,12 @@ async function findOneByUsername(username) { users WHERE LOWER(username) = LOWER($1) - LIMIT + LIMIT 1 ;`, values: [username], }); + if (results.rowCount === 0) { throw new NotFoundError({ message: "O username informado não foi encontrado no sistema.", @@ -32,22 +33,54 @@ async function findOneByUsername(username) { } } +async function findOneByEmail(email) { + const userFound = await runSelectQuery(email); + + return userFound; + + async function runSelectQuery(email) { + const results = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + LOWER(email) = LOWER($1) + LIMIT + 1 + ;`, + values: [email], + }); + + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "O email informado não foi encontrado no sistema.", + action: "Verifique se o email está digitado corretamente.", + }); + } + + return results.rows[0]; + } +} + async function create(userInputValues) { await validateUniqueUsername(userInputValues.username); await validateUniqueEmail(userInputValues.email); await hashPasswordInObject(userInputValues); - const newUser = await runInsertUserQuery(userInputValues); + const newUser = await runInsertQuery(userInputValues); return newUser; - async function runInsertUserQuery(userInputValues) { + async function runInsertQuery(userInputValues) { const results = await database.query({ text: ` - INSERT INTO + INSERT INTO users (username, email, password) - VALUES ($1, $2, $3) + VALUES + ($1, $2, $3) RETURNING - * + * ;`, values: [ userInputValues.username, @@ -76,23 +109,23 @@ async function update(username, userInputValues) { const userWithNewValues = { ...currentUser, ...userInputValues }; - const updatedUser = await runUpdateUserQuery(userWithNewValues); + const updatedUser = await runUpdateQuery(userWithNewValues); return updatedUser; - async function runUpdateUserQuery(userWithNewValues) { + async function runUpdateQuery(userWithNewValues) { const results = await database.query({ text: ` - UPDATE - users - SET - username = $2, - email = $3, - password = $4, - updated_at = timezone('utc', now()) - WHERE - id = $1 - RETURNING - * + UPDATE + users + SET + username = $2, + email = $3, + password = $4, + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * `, values: [ userWithNewValues.id, @@ -105,18 +138,20 @@ async function update(username, userInputValues) { return results.rows[0]; } } + async function validateUniqueUsername(username) { const results = await database.query({ text: ` - SELECT - username - FROM - users - WHERE - LOWER(username) = LOWER($1) - ;`, + SELECT + username + FROM + users + WHERE + LOWER(username) = LOWER($1) + ;`, values: [username], }); + if (results.rowCount > 0) { throw new ValidationError({ message: "O username informado já está sendo utilizado.", @@ -128,15 +163,16 @@ async function validateUniqueUsername(username) { async function validateUniqueEmail(email) { const results = await database.query({ text: ` - SELECT - email - FROM - users - WHERE - LOWER(email) = LOWER($1) - ;`, + SELECT + email + FROM + users + WHERE + LOWER(email) = LOWER($1) + ;`, values: [email], }); + if (results.rowCount > 0) { throw new ValidationError({ message: "O email informado já está sendo utilizado.", @@ -153,6 +189,7 @@ async function hashPasswordInObject(userInputValues) { const user = { create, findOneByUsername, + findOneByEmail, update, }; diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js new file mode 100644 index 0000000..fe622e7 --- /dev/null +++ b/pages/api/v1/sessions/index.js @@ -0,0 +1,32 @@ +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"; + +const router = createRouter(); + +router.post(postHandler); + +export default router.handler(controller.errorHandlers); + +async function postHandler(request, response) { + const userInputValues = request.body; + + const authenticatedUser = await authentication.getAuthenticatedUser( + userInputValues.email, + userInputValues.password, + ); + + 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); + + return response.status(201).json(newSession); +} diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js new file mode 100644 index 0000000..aea62a5 --- /dev/null +++ b/tests/integration/api/v1/sessions/post.test.js @@ -0,0 +1,152 @@ +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("POST /api/v1/sessions", () => { + describe("Anonymous user", () => { + test("With incorrect `email` but correct `password`", async () => { + await orchestrator.createUser({ + password: "senha-correta", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email.errado@curso.dev", + password: "senha-correta", + }), + }); + + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With correct `email` but incorrect `password`", async () => { + await orchestrator.createUser({ + email: "email.correto@curso.dev", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email.correto@curso.dev", + password: "senha-incorreta", + }), + }); + + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With incorrect `email` and incorrect `password`", async () => { + await orchestrator.createUser(); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email.incorreto@curso.dev", + password: "senha-incorreta", + }), + }); + + expect(response.status).toBe(401); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "UnauthorizedError", + message: "Dados de autenticação não conferem.", + action: "Verifique se os dados enviados estão corretos.", + status_code: 401, + }); + }); + + test("With correct `email` and correct `password`", async () => { + const createdUser = await orchestrator.createUser({ + email: "tudo.correto@curso.dev", + password: "tudocorreto", + }); + + const response = await fetch("http://localhost:3000/api/v1/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "tudo.correto@curso.dev", + password: "tudocorreto", + }), + }); + + expect(response.status).toBe(201); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + token: responseBody.token, + user_id: createdUser.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(); + + const expiresAt = new Date(responseBody.expires_at); + const createdAt = new Date(responseBody.created_at); + + expiresAt.setMilliseconds(0); + createdAt.setMilliseconds(0); + + expect(expiresAt - createdAt).toBe(session.EXPIRATION_IN_MILLISECONDS); + + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: responseBody.token, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + path: "/", + httpOnly: true, + }); + }); + }); +}); From 0ee05801bcd7c6bef25f6f2be4598aff426f8c15 Mon Sep 17 00:00:00 2001 From: jvgasparoni Date: Wed, 5 Nov 2025 15:27:13 -0300 Subject: [PATCH 3/4] style: an correction at settings.json where prettier failed to check --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ffd84d8..26afefd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -66,4 +66,4 @@ "source.fixAll.eslint": false } } -} \ No newline at end of file +} From 708575c20f615c471c948334010604ad452889af Mon Sep 17 00:00:00 2001 From: jvgasparoni Date: Wed, 5 Nov 2025 15:30:03 -0300 Subject: [PATCH 4/4] docs: add `.vscode` at `.prettierignore` file --- .prettierignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index d0d878e..6193d00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -.next \ No newline at end of file +.next +.vscode \ No newline at end of file