From da9cfff6e5071f4aec428f6f639cf6ebb7f3db13 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:09:29 +0000 Subject: [PATCH 01/22] feat: add `features` column to `users` table --- .../migrations/1781880952907_add-features-to-users.js | 11 +++++++++++ tests/integration/api/v1/user/get.test.js | 2 ++ tests/integration/api/v1/users/[username]/get.test.js | 2 ++ .../integration/api/v1/users/[username]/patch.test.js | 3 +++ tests/integration/api/v1/users/post.test.js | 1 + 5 files changed, 19 insertions(+) create mode 100644 infra/migrations/1781880952907_add-features-to-users.js diff --git a/infra/migrations/1781880952907_add-features-to-users.js b/infra/migrations/1781880952907_add-features-to-users.js new file mode 100644 index 0000000..f2d73c4 --- /dev/null +++ b/infra/migrations/1781880952907_add-features-to-users.js @@ -0,0 +1,11 @@ +exports.up = (pgm) => { + pgm.addColumn("users", { + features: { + type: "varchar[]", + notNull: true, + default: "{}", + }, + }); +}; + +exports.down = false; diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 3beb826..f5e19f0 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -35,6 +35,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithValidSession", email: createUser.email, + features: [], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: createUser.update_at.toISOString(), @@ -205,6 +206,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithSession", email: createUser.email, + features: [], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: createUser.update_at.toISOString(), diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 63d2590..353a734 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -27,6 +27,7 @@ describe("GET to /api/v1/users/[username]", () => { id: response2Body.id, username: "MesmoCase", email: "mesmo.case@gmail.com", + features: [], password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, @@ -55,6 +56,7 @@ describe("GET to /api/v1/users/[username]", () => { id: response2Body.id, username: "CaseDiferente", email: "case.diferente@gmail.com", + features: [], password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index c125a70..993e0b8 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -119,6 +119,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser2", email: createUser.email, + features: [], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -157,6 +158,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: createUser.username, email: "uniqueEmail2@gmail.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -195,6 +197,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: createUser.username, email: createUser.email, + features: [], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 7f61843..3432981 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -32,6 +32,7 @@ describe("POST to /api/v1/users", () => { id: responseBody.id, username: "guilhermeMarinho", email: "guimars@gmail.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, From a4d78dd7c9d6a0a407d21e62a282ecc374091afb Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:28:48 +0000 Subject: [PATCH 02/22] feat: add default feature `read:activation_token` when creating `user` --- tests/integration/api/v1/user/get.test.js | 4 ++-- tests/integration/api/v1/users/[username]/get.test.js | 4 ++-- tests/integration/api/v1/users/[username]/patch.test.js | 6 +++--- tests/integration/api/v1/users/post.test.js | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index f5e19f0..47b36d7 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -35,7 +35,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithValidSession", email: createUser.email, - features: [], + features: ["read:activation_token"], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: createUser.update_at.toISOString(), @@ -206,7 +206,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithSession", email: createUser.email, - features: [], + features: ["read:activation_token"], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: createUser.update_at.toISOString(), diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 353a734..0d96ae7 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -27,7 +27,7 @@ describe("GET to /api/v1/users/[username]", () => { id: response2Body.id, username: "MesmoCase", email: "mesmo.case@gmail.com", - features: [], + features: ["read:activation_token"], password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, @@ -56,7 +56,7 @@ describe("GET to /api/v1/users/[username]", () => { id: response2Body.id, username: "CaseDiferente", email: "case.diferente@gmail.com", - features: [], + features: ["read:activation_token"], password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 993e0b8..056b71c 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -119,7 +119,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser2", email: createUser.email, - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -158,7 +158,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: createUser.username, email: "uniqueEmail2@gmail.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -197,7 +197,7 @@ describe("PATCH to /api/v1/users/[username]", () => { id: responseBody.id, username: createUser.username, email: createUser.email, - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 3432981..bd46294 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -32,7 +32,7 @@ describe("POST to /api/v1/users", () => { id: responseBody.id, username: "guilhermeMarinho", email: "guimars@gmail.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, From 2e27aea37d06ae6b272c44bf0feb247c93b02f61 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:10:23 +0000 Subject: [PATCH 03/22] test: tests by email registraion flow --- .../_user-case/registration-flow.test.js | 61 +++++++++++++++++++ tests/orchestrator.js | 4 ++ 2 files changed, 65 insertions(+) create mode 100644 tests/integration/_user-case/registration-flow.test.js diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js new file mode 100644 index 0000000..f6c4914 --- /dev/null +++ b/tests/integration/_user-case/registration-flow.test.js @@ -0,0 +1,61 @@ +import activation from "models/activation"; +import orchestrator from "tests/orchestrator"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); + await orchestrator.deleteAllEmails(); +}); + +describe("Use case: Registration Flow (all successful)", () => { + let createUserResponseBody; + test("Create user account", async () => { + const createUserResponse = await fetch( + "http://localhost:3000/api/v1/users", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "RegistrationFlow", + email: "registraion.flow@xedit.com", + password: "RegistrationFlowPassword", + }), + }, + ); + expect(createUserResponse.status).toBe(201); + + createUserResponseBody = await createUserResponse.json(); + expect(createUserResponseBody).toEqual({ + id: createUserResponseBody.id, + username: "RegistrationFlow", + email: "registraion.flow@xedit.com", + features: ["read:activation_token"], + password: createUserResponseBody.password, + created_at: createUserResponseBody.created_at, + update_at: createUserResponseBody.update_at, + }); + }); + + test("Receive activation email", async () => { + const lastEmail = await orchestrator.getLastEmail(); + + const activationToken = await activation.findOndeByUserId( + createUserResponseBody.id, + ); + + expect(lastEmail.sender).toBe(""); + expect(lastEmail.recipients[0]).toBe(""); + expect(lastEmail.subject).toBe("Ative seu cadastro no Xedit!"); + expect(lastEmail.text).toContain("RegistrationFlow"); + expect(lastEmail.text).toContain(activationToken.id); + }); + + test("Activate account", async () => {}); + + test("Login", async () => {}); + + test("Get user information", async () => {}); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index dfeaa4a..bb5c80c 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -83,6 +83,10 @@ async function getLastEmail() { const emailListBody = await emailListResponse.json(); const lastEmailItem = emailListBody.pop(); + if (!lastEmailItem) { + return null; + } + const emailTextResponse = await fetch( `${emailHttpUrl}/messages/${lastEmailItem.id}.plain`, ); From 9446dfa0b35cfa4ef0258a20d8de0ac0705204a3 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:13:23 +0000 Subject: [PATCH 04/22] feat: send activation email after `user` registration --- ...143252549_create-user-activation-tokens.js | 36 +++++++++ infra/webserver.js | 17 +++++ models/activation.js | 73 +++++++++++++++++++ models/user.js | 10 ++- pages/api/v1/users/index.js | 7 ++ 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 infra/migrations/1782143252549_create-user-activation-tokens.js create mode 100644 infra/webserver.js create mode 100644 models/activation.js diff --git a/infra/migrations/1782143252549_create-user-activation-tokens.js b/infra/migrations/1782143252549_create-user-activation-tokens.js new file mode 100644 index 0000000..222f233 --- /dev/null +++ b/infra/migrations/1782143252549_create-user-activation-tokens.js @@ -0,0 +1,36 @@ +exports.up = (pgm) => { + pgm.createTable("user_activation_tokens", { + id: { + type: "uuid", + primaryKey: true, + default: pgm.func("gen_random_uuid()"), + }, + + used_at: { + type: "timestamptz", + notNull: false, + }, + + user_id: { + type: "uuid", + notNull: true, + }, + + 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())"), + }, + }); +}; diff --git a/infra/webserver.js b/infra/webserver.js new file mode 100644 index 0000000..e83ee65 --- /dev/null +++ b/infra/webserver.js @@ -0,0 +1,17 @@ +function getOrigin() { + if (["test", "development"].includes(process.env.NODE_ENV)) { + return "http//localhost:3000"; + } + + if (process.env.VERCEL_ENV === "preview") { + return `http//${process.env.VERCEL_ENV}`; + } + + return "https://xedit.com.br"; +} + +const webServer = { + origin: getOrigin(), +}; + +export default webServer; diff --git a/models/activation.js b/models/activation.js new file mode 100644 index 0000000..7000cdd --- /dev/null +++ b/models/activation.js @@ -0,0 +1,73 @@ +import email from "infra/email"; +import database from "infra/database"; +import webServer from "infra/webserver"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes + +async function create(userId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newToken = await runInsertQuery(userId, expiresAt); + return newToken; + + async function runInsertQuery(userId, expiresAt) { + const results = await database.query({ + text: ` + INSERT INTO + user_activation_tokens (user_id, expires_at) + VALUES + ($1, $2) + RETURNING + * + ;`, + values: [userId, expiresAt], + }); + + return results.rows[0]; + } +} + +async function findOndeByUserId(userId) { + const newToken = await runInsertQuery(userId); + return newToken; + + async function runInsertQuery(userId) { + const results = await database.query({ + text: ` + SELECT + * + FROM + user_activation_tokens + WHERE + user_id = $1 + LIMIT + 1 + ;`, + values: [userId], + }); + + return results.rows[0]; + } +} + +async function sendEmailToUser(user, activationToken) { + await email.send({ + from: "Xedit ", + to: user.email, + subject: "Ative seu cadastro no Xedit!", + text: `${user.username}, clique no link abaixo para ativar seu cadastro no Xedit + +${webServer.origin}/cadastro/ativar/${activationToken.id} + +Atenciosamente, +Equipe Xedit`, + }); +} + +const activation = { + sendEmailToUser, + create, + findOndeByUserId, +}; + +export default activation; diff --git a/models/user.js b/models/user.js index 5eb7cff..3115699 100644 --- a/models/user.js +++ b/models/user.js @@ -99,6 +99,7 @@ async function create(userInputValue) { await validateUniqueEmail(userInputValue.email); await validateUniqueUsername(userInputValue.username); await hashPasswordInObject(userInputValue); + injectDefaultFeaturesInObject(userInputValue); const newUser = await runInsertQuery(userInputValue); return newUser; @@ -107,9 +108,9 @@ async function create(userInputValue) { const result = await database.query({ text: ` INSERT INTO - users (username, email, password) + users (username, email, password, features) VALUES - ($1, $2, $3) + ($1, $2, $3, $4) RETURNING * ;`, @@ -117,10 +118,15 @@ async function create(userInputValue) { userInputValue.username, userInputValue.email, userInputValue.password, + userInputValue.features, ], }); return result.rows[0]; } + + function injectDefaultFeaturesInObject(userInputValue) { + userInputValue.features = ["read:activation_token"]; + } } async function update(username, userInputValue) { diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index a58754a..b00012f 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -1,6 +1,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import activation from "models/activation"; const router = createRouter(); @@ -11,5 +12,11 @@ export default router.handler(controller.errorHandlers); async function postHandler(request, response) { const userInputValue = request.body; const newUser = await user.create(userInputValue); + + // 1. Criar o Token de ativação + const activationToken = await activation.create(newUser.id); + // 2. Enviar esse Token por Email + await activation.sendEmailToUser(newUser, activationToken); + return response.status(201).json(newUser); } From 4550df518d89ac749cb47e56e12b575adf063315 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:24:10 +0000 Subject: [PATCH 05/22] feat: add `activation.findOneValidById()` and `orchestrator.extractUUID()` --- models/activation.js | 75 ++++++++++++------- .../_user-case/registration-flow.test.js | 18 +++-- tests/orchestrator.js | 6 ++ 3 files changed, 64 insertions(+), 35 deletions(-) diff --git a/models/activation.js b/models/activation.js index 7000cdd..22306a6 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,49 +1,60 @@ import email from "infra/email"; import database from "infra/database"; import webServer from "infra/webserver"; +import { NotFoundError } from "infra/errors"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes -async function create(userId) { - const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); - - const newToken = await runInsertQuery(userId, expiresAt); - return newToken; +async function findOneValidById(tokenId) { + const activationTokenObject = await runSelectQuery(tokenId); + return activationTokenObject; - async function runInsertQuery(userId, expiresAt) { + async function runSelectQuery(tokenId) { const results = await database.query({ text: ` - INSERT INTO - user_activation_tokens (user_id, expires_at) - VALUES - ($1, $2) - RETURNING + SELECT * + FROM + user_activation_tokens + WHERE + id = $1 + AND expires_at > NOW() + AND used_at IS NULL + LIMIT + 1 ;`, - values: [userId, expiresAt], + values: [tokenId], }); + if (results.rowCount === 0) { + throw new NotFoundError({ + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + }); + } + return results.rows[0]; } } -async function findOndeByUserId(userId) { - const newToken = await runInsertQuery(userId); +async function create(userId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newToken = await runInsertQuery(userId, expiresAt); return newToken; - async function runInsertQuery(userId) { + async function runInsertQuery(userId, expiresAt) { const results = await database.query({ text: ` - SELECT + INSERT INTO + user_activation_tokens (user_id, expires_at) + VALUES + ($1, $2) + RETURNING * - FROM - user_activation_tokens - WHERE - user_id = $1 - LIMIT - 1 ;`, - values: [userId], + values: [userId, expiresAt], }); return results.rows[0]; @@ -52,22 +63,28 @@ async function findOndeByUserId(userId) { async function sendEmailToUser(user, activationToken) { await email.send({ - from: "Xedit ", + from: "Xedit ", to: user.email, subject: "Ative seu cadastro no Xedit!", - text: `${user.username}, clique no link abaixo para ativar seu cadastro no Xedit - + text: ` +Olá, ${user.username}! + +Clique no link abaixo para ativar seu cadastro no Xedit: + ${webServer.origin}/cadastro/ativar/${activationToken.id} - + +Se você não solicitou este cadastro, ignore este email. + Atenciosamente, -Equipe Xedit`, +Equipe Xedit +`.trim(), }); } const activation = { sendEmailToUser, create, - findOndeByUserId, + findOneValidById, }; export default activation; diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js index f6c4914..9a392b3 100644 --- a/tests/integration/_user-case/registration-flow.test.js +++ b/tests/integration/_user-case/registration-flow.test.js @@ -1,5 +1,6 @@ -import activation from "models/activation"; import orchestrator from "tests/orchestrator"; +import webServer from "infra/webserver"; +import activation from "models/activation"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -42,15 +43,20 @@ describe("Use case: Registration Flow (all successful)", () => { test("Receive activation email", async () => { const lastEmail = await orchestrator.getLastEmail(); - const activationToken = await activation.findOndeByUserId( - createUserResponseBody.id, - ); - expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Ative seu cadastro no Xedit!"); expect(lastEmail.text).toContain("RegistrationFlow"); - expect(lastEmail.text).toContain(activationToken.id); + + const activationTokenId = orchestrator.extractUUID(lastEmail.text); + expect(lastEmail.text).toContain( + `${webServer.origin}/cadastro/ativar/${activationTokenId}`, + ); + + const activationTokenObject = + await activation.findOneValidById(activationTokenId); + expect(activationTokenObject.user_id).toBe(createUserResponseBody.id); + expect(activationTokenObject.used_at).toBe(null); }); test("Activate account", async () => {}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index bb5c80c..cebe603 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -96,6 +96,11 @@ async function getLastEmail() { return lastEmailItem; } +function extractUUID(text) { + const match = text.match(/[0-9a-fA-F-]{36}/); + return match ? match[0] : null; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -104,6 +109,7 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, }; export default orchestrator; From e1e29faff52989f2cfa8e993378f9657db5c2b16 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:53:02 +0000 Subject: [PATCH 06/22] feat: create method by activation and user models --- models/activation.js | 32 ++++++++++++++++++++ models/user.js | 31 +++++++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 22 ++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 pages/api/v1/activations/[token_id]/index.js diff --git a/models/activation.js b/models/activation.js index 22306a6..b1c3532 100644 --- a/models/activation.js +++ b/models/activation.js @@ -2,6 +2,7 @@ import email from "infra/email"; import database from "infra/database"; import webServer from "infra/webserver"; import { NotFoundError } from "infra/errors"; +import user from "models/user"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -81,10 +82,41 @@ Equipe Xedit }); } +async function markTokenAsUsed(activationTokenId) { + const activationTokenObject = await runUpdateQuery(activationTokenId); + return activationTokenObject; + + async function runUpdateQuery(activationTokenId) { + const results = await database.query({ + text: ` + UPDATE + user_activation_tokens + SET + used_at = timezone('utc', now()), + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [activationTokenId], + }); + + return results.rows[0]; + } +} + +async function activateUserByUserId(userId) { + const activatedUser = await user.setFeature(userId, ["create:session"]); + return activatedUser; +} + const activation = { sendEmailToUser, create, findOneValidById, + markTokenAsUsed, + activateUserByUserId, }; export default activation; diff --git a/models/user.js b/models/user.js index 3115699..5dcb04b 100644 --- a/models/user.js +++ b/models/user.js @@ -223,12 +223,43 @@ async function hashPasswordInObject(userInputValue) { userInputValue.password = hashedPassword; } +async function setFeature(userId, features) { + const updateUser = await runUpdateQuery(userId, features); + return updateUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = $2, + update_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "Usuário não encontrado.", + action: "Verifique o identificador informado.", + }); + } + + return results.rows[0]; + } +} + const user = { create, findOneById, findOneByUsername, findOneByEmail, update, + setFeature, }; export default user; diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js new file mode 100644 index 0000000..9140cbb --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,22 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import activation from "models/activation"; + +const router = createRouter(); + +router.patch(patchHandler); + +export default router.handler(controller.errorHandlers); + +async function patchHandler(request, response) { + const activationTokenId = request.query.token_id; + + const validActivationToken = + await activation.findOneValidById(activationTokenId); + const usedActivationToken = + await activation.markTokenAsUsed(activationTokenId); + + await activation.activateUserByUserId(validActivationToken.user_id); + + return response.status(200).json(usedActivationToken); +} From 05859a3414b94de49214e8d8e0fe48ed4c891970 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:01:49 +0000 Subject: [PATCH 07/22] feat: create `authorization` model and use it inside `\sessions` controller --- infra/authorization.js | 15 ++++++++ infra/controller.js | 66 +++++++++++++++++++++++++++++++--- infra/errors.js | 22 ++++++++++++ models/user.js | 4 +-- pages/api/v1/sessions/index.js | 13 ++++++- 5 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 infra/authorization.js diff --git a/infra/authorization.js b/infra/authorization.js new file mode 100644 index 0000000..943e461 --- /dev/null +++ b/infra/authorization.js @@ -0,0 +1,15 @@ +function can(user, feature) { + let authorized = false; + + if (user.features.includes(feature)) { + authorized = true; + } + + return authorized; +} + +const authorization = { + can, +}; + +export default authorization; diff --git a/infra/controller.js b/infra/controller.js index e7dec5e..6c8c973 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,11 +1,15 @@ -import * as cookie from "cookie"; +import * as cookies from "cookie"; import session from "models/session"; +import user from "models/user"; +import authorization from "./authorization"; + import { InternalServerError, MethodNotAllowedError, ValidationError, NotFoundError, UnauthorizedError, + ForbiddenError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -14,7 +18,11 @@ 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 ForbiddenError + ) { return response.status(error.statusCode).json(error); } @@ -33,7 +41,7 @@ function onErrorHandler(error, request, response) { } async function setSessionCookie(sessionToken, response) { - const setCookie = cookie.serialize("session_id", sessionToken, { + const setCookie = cookies.serialize("session_id", sessionToken, { path: "/", maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, secure: process.env.NODE_ENV === "production", @@ -44,7 +52,7 @@ async function setSessionCookie(sessionToken, response) { } async function clearSessionCookie(response) { - const setCookie = cookie.serialize("session_id", "invalid", { + const setCookie = cookies.serialize("session_id", "invalid", { path: "/", maxAge: -1, secure: process.env.NODE_ENV === "production", @@ -54,6 +62,53 @@ async function clearSessionCookie(response) { response.setHeader("Set-Cookie", setCookie); } +async function injectAnonymousOrUser(request, response, next) { + if (request.cookies?.session_id) { + await injectAutenticatedUser(request); + return next(); + } + + injectAnonymousUser(request); + return next(); +} + +async function injectAutenticatedUser(request) { + const sessionToken = request.cookies.session_id; + const sessionObject = await session.findOneValidByToken(sessionToken); + const userObject = await user.findOneById(sessionObject.user_id); + + request.context = { + ...request.context, + user: userObject, + }; +} + +async function injectAnonymousUser(request) { + const anonymousUserObject = { + features: ["read:activation_token", "create:session", "create:user"], + }; + + request.context = { + ...request.context, + user: anonymousUserObject, + }; +} + +function canRequest(feature) { + return function canRequestMiddleware(request, response, next) { + const userTryingToRequest = request.context.user; + + if (authorization.can(userTryingToRequest, feature)) { + return next(); + } + + throw new ForbiddenError({ + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "${feature}"`, + }); + }; +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, @@ -61,6 +116,9 @@ const controller = { }, setSessionCookie, clearSessionCookie, + injectAnonymousOrUser, + injectAutenticatedUser, + canRequest, }; export default controller; diff --git a/infra/errors.js b/infra/errors.js index e9c32a9..b9f5965 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -102,6 +102,28 @@ export class UnauthorizedError extends Error { } } +export class ForbiddenError extends Error { + constructor({ cause, message, action }) { + super(message || "Acesso negado.", { + cause, + }); + + this.name = "ForbiddenError"; + this.action = + action || "Verifique as features necessárias antes de continuar."; + this.statusCode = 403; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + statusCode: this.statusCode, + }; + } +} + export class MethodNotAllowedError extends Error { constructor() { super("Metodo não permitido para este endpoint."); diff --git a/models/user.js b/models/user.js index 5dcb04b..5da3ec7 100644 --- a/models/user.js +++ b/models/user.js @@ -255,11 +255,11 @@ async function setFeature(userId, features) { const user = { create, + update, + setFeature, findOneById, findOneByUsername, findOneByEmail, - update, - setFeature, }; export default user; diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 331b4c9..a1c79ed 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,11 +1,15 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import authentication from "models/authentication"; +import authorization from "infra/authorization"; import session from "models/session"; +import { ForbiddenError } from "infra/errors"; + const router = createRouter(); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:session"), postHandler); router.delete(deleteHandler); export default router.handler(controller.errorHandlers); @@ -18,6 +22,13 @@ async function postHandler(request, response) { userInputValue.password, ); + if (!authorization.can(authenticatedUser, "create:session")) { + throw new ForbiddenError({ + message: "Você não possui permissão para fazer login.", + action: "Contate o suporte para caso tenha alguma dúvida.", + }); + } + const newSession = await session.create(authenticatedUser.id); controller.setSessionCookie(newSession.token, response); From 52e2430832e6d72928a599d52e7c62e13b3b8f5e Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:03:03 +0000 Subject: [PATCH 08/22] test: refact by tests for sessions valid --- tests/integration/api/v1/sessions/delete.test.js | 2 ++ tests/integration/api/v1/sessions/post.test.js | 2 ++ tests/orchestrator.js | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index 8620fd3..b848cd4 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -78,6 +78,8 @@ describe("DELETE to /api/v1/sessions", () => { Cookie: `session_id=${sessionObject.token}`, }, }); + + console.log(response.status); expect(response.status).toBe(200); const responseBody = await response.json(); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index b0e7543..4b67f86 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -95,6 +95,8 @@ describe("POST to /api/v1/sessions", () => { password: "tudocorreto", }); + await orchestrator.activatedUser(createdUser); + const response = await fetch("http://localhost:3000/api/v1/sessions", { method: "POST", headers: { diff --git a/tests/orchestrator.js b/tests/orchestrator.js index cebe603..babbbe1 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -2,6 +2,7 @@ import retry from "async-retry"; import { faker } from "@faker-js/faker"; import dotenv from "dotenv"; +import activation from "models/activation"; import database from "infra/database"; import migrator from "models/migrator"; import user from "models/user"; @@ -101,6 +102,10 @@ function extractUUID(text) { return match ? match[0] : null; } +async function activatedUser(inactivedUser) { + return await activation.activateUserByUserId(inactivedUser.id); +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -110,6 +115,7 @@ const orchestrator = { deleteAllEmails, getLastEmail, extractUUID, + activatedUser, }; export default orchestrator; From 46688bba407ba42319fc6912e0833d234c3969f3 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:51:20 +0000 Subject: [PATCH 09/22] feat: require `read:session` to access `\user` endpoint --- infra/controller.js | 1 + models/activation.js | 5 ++++- pages/api/v1/user/index.js | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 6c8c973..46f5fc8 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -74,6 +74,7 @@ async function injectAnonymousOrUser(request, response, next) { async function injectAutenticatedUser(request) { const sessionToken = request.cookies.session_id; + const sessionObject = await session.findOneValidByToken(sessionToken); const userObject = await user.findOneById(sessionObject.user_id); diff --git a/models/activation.js b/models/activation.js index b1c3532..d4ef3f8 100644 --- a/models/activation.js +++ b/models/activation.js @@ -107,7 +107,10 @@ async function markTokenAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { - const activatedUser = await user.setFeature(userId, ["create:session"]); + const activatedUser = await user.setFeature(userId, [ + "create:session", + "read:session", + ]); return activatedUser; } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index cbc8e1c..5c70647 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -5,7 +5,8 @@ import session from "models/session"; const router = createRouter(); -router.get(getHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:session"), getHandler); export default router.handler(controller.errorHandlers); From 7a7f6bc1e28c6eadaf786752d19b7f4de1b0292d Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:53:29 +0000 Subject: [PATCH 10/22] test: create and refact test for `read:session` by users default and anonymous --- .../_user-case/registration-flow.test.js | 54 +++++++++++++++++-- .../integration/api/v1/sessions/post.test.js | 2 +- tests/integration/api/v1/user/get.test.js | 27 ++++++++-- tests/orchestrator.js | 6 +-- 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js index 9a392b3..977e9b4 100644 --- a/tests/integration/_user-case/registration-flow.test.js +++ b/tests/integration/_user-case/registration-flow.test.js @@ -1,6 +1,7 @@ import orchestrator from "tests/orchestrator"; import webServer from "infra/webserver"; import activation from "models/activation"; +import user from "models/user"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -11,6 +12,9 @@ beforeAll(async () => { describe("Use case: Registration Flow (all successful)", () => { let createUserResponseBody; + let activationTokenId; + let createSessionsResponseBody; + test("Create user account", async () => { const createUserResponse = await fetch( "http://localhost:3000/api/v1/users", @@ -48,7 +52,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(lastEmail.subject).toBe("Ative seu cadastro no Xedit!"); expect(lastEmail.text).toContain("RegistrationFlow"); - const activationTokenId = orchestrator.extractUUID(lastEmail.text); + activationTokenId = orchestrator.extractUUID(lastEmail.text); expect(lastEmail.text).toContain( `${webServer.origin}/cadastro/ativar/${activationTokenId}`, ); @@ -59,9 +63,51 @@ describe("Use case: Registration Flow (all successful)", () => { expect(activationTokenObject.used_at).toBe(null); }); - test("Activate account", async () => {}); + test("Activate account", async () => { + const activationResponse = await fetch( + `http://localhost:3000/api/v1/activations/${activationTokenId}`, + { + method: "PATCH", + }, + ); + expect(activationResponse.status).toBe(200); + + const activationResponseBody = await activationResponse.json(); + expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); + + const activatedUser = await user.findOneByUsername("RegistrationFlow"); + expect(activatedUser.features).toEqual(["create:session", "read:session"]); + }); + + test("Login", async () => { + const createSessionsResponse = await fetch( + "http://localhost:3000/api/v1/sessions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "registraion.flow@xedit.com", + password: "RegistrationFlowPassword", + }), + }, + ); + expect(createSessionsResponse.status).toBe(201); + + createSessionsResponseBody = await createSessionsResponse.json(); + expect(createSessionsResponseBody.user_id).toBe(createUserResponseBody.id); + }); - test("Login", async () => {}); + test("Get user information", async () => { + const userResponse = await fetch("http://localhost:3000/api/v1/user", { + headers: { + cookie: `session_id=${createSessionsResponseBody.token}`, + }, + }); + expect(userResponse.status).toBe(200); - test("Get user information", async () => {}); + const userResponseBody = await userResponse.json(); + expect(userResponseBody.id).toBe(createUserResponseBody.id); + }); }); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 4b67f86..81aec28 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -95,7 +95,7 @@ describe("POST to /api/v1/sessions", () => { password: "tudocorreto", }); - await orchestrator.activatedUser(createdUser); + await orchestrator.activateUser(createdUser); const response = await fetch("http://localhost:3000/api/v1/sessions", { method: "POST", diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 47b36d7..0e3bca6 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -10,12 +10,29 @@ beforeAll(async () => { }); describe("GET to /api/v1/user", () => { + describe("Anonymous user", () => { + test("Retrienving the endpoint", async () => { + const response = await fetch("http://localhost:3000/api/v1/user"); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:session"`, + statusCode: 403, + }); + }); + }); + describe("Default user", () => { test("With valid session", async () => { const createUser = await orchestrator.createUser({ username: "UserWithValidSession", }); + const activatedUser = await orchestrator.activateUser(createUser); const sessionObject = await orchestrator.createSession(createUser.id); const response = await fetch("http://localhost:3000/api/v1/user", { @@ -35,10 +52,10 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithValidSession", email: createUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session"], password: createUser.password, created_at: createUser.created_at.toISOString(), - update_at: createUser.update_at.toISOString(), + update_at: activatedUser.update_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toEqual(4); @@ -188,6 +205,8 @@ describe("GET to /api/v1/user", () => { username: "UserWithSession", }); + const activatedUser = await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); jest.useRealTimers(); @@ -206,10 +225,10 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithSession", email: createUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session"], password: createUser.password, created_at: createUser.created_at.toISOString(), - update_at: createUser.update_at.toISOString(), + update_at: activatedUser.update_at.toISOString(), }); }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index babbbe1..7f342a2 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -102,8 +102,8 @@ function extractUUID(text) { return match ? match[0] : null; } -async function activatedUser(inactivedUser) { - return await activation.activateUserByUserId(inactivedUser.id); +async function activateUser(inactiveUser) { + return await activation.activateUserByUserId(inactiveUser.id); } const orchestrator = { @@ -115,7 +115,7 @@ const orchestrator = { deleteAllEmails, getLastEmail, extractUUID, - activatedUser, + activateUser, }; export default orchestrator; From d52cbefa22746cea8c1b93b1ff1ef00eec3ed8cd Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:16:00 +0000 Subject: [PATCH 11/22] feat: require `read:activation_token` to acess `\api\v1\activation\[token_id]` --- models/activation.js | 12 +- pages/api/v1/activations/[token_id]/index.js | 8 +- .../v1/activations/[token_id]/patch.test.js | 117 ++++++++++++++++++ tests/orchestrator.js | 9 +- 4 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 tests/integration/api/v1/activations/[token_id]/patch.test.js diff --git a/models/activation.js b/models/activation.js index d4ef3f8..dfe8f32 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,8 +1,9 @@ import email from "infra/email"; import database from "infra/database"; import webServer from "infra/webserver"; -import { NotFoundError } from "infra/errors"; +import { ForbiddenError, NotFoundError } from "infra/errors"; import user from "models/user"; +import authorization from "infra/authorization"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -107,6 +108,15 @@ async function markTokenAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { + const userToActivate = await user.findOneById(userId); + + if (!authorization.can(userToActivate, "read:activation_token")) { + throw new ForbiddenError({ + message: "Você não pode mais utlizar tokens de ativação.", + action: "Entre em contato com o suporte.", + }); + } + const activatedUser = await user.setFeature(userId, [ "create:session", "read:session", diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js index 9140cbb..69e488a 100644 --- a/pages/api/v1/activations/[token_id]/index.js +++ b/pages/api/v1/activations/[token_id]/index.js @@ -4,7 +4,8 @@ import activation from "models/activation"; const router = createRouter(); -router.patch(patchHandler); +router.use(controller.injectAnonymousOrUser); +router.patch(controller.canRequest("read:activation_token"), patchHandler); export default router.handler(controller.errorHandlers); @@ -13,10 +14,11 @@ async function patchHandler(request, response) { const validActivationToken = await activation.findOneValidById(activationTokenId); - const usedActivationToken = - await activation.markTokenAsUsed(activationTokenId); await activation.activateUserByUserId(validActivationToken.user_id); + const usedActivationToken = + await activation.markTokenAsUsed(activationTokenId); + return response.status(200).json(usedActivationToken); } diff --git a/tests/integration/api/v1/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js new file mode 100644 index 0000000..8c04992 --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,117 @@ +import orchestrator from "../../../../../orchestrator"; + +import activation from "models/activation"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH to /api/v1/activations/[token_id]", () => { + describe("Anonymous user", () => { + test("With nonexistent token", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/activations/5464bcba-2345-423e-8334-988fd17ee71e", + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "NotFoundError", + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + statusCode: 404, + }); + }); + + test("With already used token", async () => { + const createdUser = await orchestrator.createUser(); + const activationToken = await activation.create(createdUser.id); + + const response1 = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response1.status).toBe(200); + + const response2 = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response2.status).toBe(404); + + const response2Body = await response2.json(); + expect(response2Body).toEqual({ + name: "NotFoundError", + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + statusCode: 404, + }); + }); + + test("With valid token but alreadt activates user", async () => { + const createUser = await orchestrator.createUser(); + await orchestrator.activateUser(createUser); + const activationToken = await activation.create(createUser.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não pode mais utlizar tokens de ativação.", + action: "Entre em contato com o suporte.", + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { + test("With valid token, but already logged in user", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1SessionObject = await orchestrator.createSession(user1.id); + + const user2 = await orchestrator.createUser(); + const user2ActivationToken = await activation.create(user2.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${user2ActivationToken.id}`, + { + method: "PATCH", + headers: { + Cookie: `session_id=${user1SessionObject.token}`, + }, + }, + ); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: + 'Verifique se o seu usuário possui a feature "read:activation_token"', + statusCode: 403, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 7f342a2..056e235 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -64,12 +64,11 @@ async function runPendingMigrations() { await migrator.runPendingMigrations(); } -async function createUser(userObject) { +async function createUser({ username, email, password } = {}) { return await user.create({ - username: - userObject.username || faker.internet.username().replace(/[_.-]/g, ""), - email: userObject.email || faker.internet.email(), - password: userObject.password || "validpassword", + username: username || faker.internet.username().replace(/[_.-]/g, ""), + email: email || faker.internet.email(), + password: password || "validpassword", }); } From b9facaaaa83e6984da48961a6d54a753a34b3cd2 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:29:15 +0000 Subject: [PATCH 12/22] feat: require `create:user` to access `api\v1\users` --- pages/api/v1/users/index.js | 5 ++-- tests/integration/api/v1/users/post.test.js | 32 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index b00012f..1d74174 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -5,7 +5,8 @@ import activation from "models/activation"; const router = createRouter(); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:user"), postHandler); export default router.handler(controller.errorHandlers); @@ -13,9 +14,7 @@ async function postHandler(request, response) { const userInputValue = request.body; const newUser = await user.create(userInputValue); - // 1. Criar o Token de ativação const activationToken = await activation.create(newUser.id); - // 2. Enviar esse Token por Email await activation.sendEmailToUser(newUser, activationToken); return response.status(201).json(newUser); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index bd46294..2ec7ebc 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -135,4 +135,36 @@ describe("POST to /api/v1/users", () => { }); }); }); + + describe("Default user", () => { + test("With unique and valid data", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1SessionOject = await orchestrator.createSession(user1.id); + + const user2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${user1SessionOject.token}`, + }, + body: JSON.stringify({ + username: "usueriologado", + email: "usueriologado@xedit.com", + password: "senha123", + }), + }); + + expect(user2Response.status).toBe(403); + + const user2ResponseBody = await user2Response.json(); + + expect(user2ResponseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "create:user"', + statusCode: 403, + }); + }); + }); }); From c39a80054367c65962fee69f659f25adb4a73a5c Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:24:06 +0000 Subject: [PATCH 13/22] feat: refacute endpoint `api\v1\users\[username]` --- models/activation.js | 1 + pages/api/v1/users/[username]/index.js | 3 +- .../_user-case/registration-flow.test.js | 6 +- tests/integration/api/v1/user/get.test.js | 4 +- .../api/v1/users/[username]/patch.test.js | 104 +++++++++++++----- 5 files changed, 86 insertions(+), 32 deletions(-) diff --git a/models/activation.js b/models/activation.js index dfe8f32..7d9e7fb 100644 --- a/models/activation.js +++ b/models/activation.js @@ -120,6 +120,7 @@ async function activateUserByUserId(userId) { const activatedUser = await user.setFeature(userId, [ "create:session", "read:session", + "update:user", ]); return activatedUser; } diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index cf6cb8c..8c62eaf 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -4,8 +4,9 @@ import user from "models/user"; const router = createRouter(); +router.use(controller.injectAnonymousOrUser); router.get(getHandler); -router.patch(patchHandler); +router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js index 977e9b4..9213821 100644 --- a/tests/integration/_user-case/registration-flow.test.js +++ b/tests/integration/_user-case/registration-flow.test.js @@ -76,7 +76,11 @@ describe("Use case: Registration Flow (all successful)", () => { expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); const activatedUser = await user.findOneByUsername("RegistrationFlow"); - expect(activatedUser.features).toEqual(["create:session", "read:session"]); + expect(activatedUser.features).toEqual([ + "create:session", + "read:session", + "update:user", + ]); }); test("Login", async () => { diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 0e3bca6..26b5c51 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -52,7 +52,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithValidSession", email: createUser.email, - features: ["create:session", "read:session"], + features: ["create:session", "read:session", "update:user"], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: activatedUser.update_at.toISOString(), @@ -225,7 +225,7 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithSession", email: createUser.email, - features: ["create:session", "read:session"], + features: ["create:session", "read:session", "update:user"], password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: activatedUser.update_at.toISOString(), diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 056b71c..03b3780 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -11,17 +11,51 @@ beforeAll(async () => { describe("PATCH to /api/v1/users/[username]", () => { describe("Anonymous user", () => { + test("With unique 'username'", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueUser1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser2", + }), + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + action: 'Verifique se o seu usuário possui a feature "update:user"', + message: "Você não possui permissão para executar esta ação.", + name: "ForbiddenError", + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { test("With nonesistent 'username'", async () => { + const createdUser = await orchestrator.createUser(); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); + const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioInexistente", { method: "PATCH", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, }, ); expect(response.status).toBe(404); const responseBody = await response.json(); - expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informando não foi encotrado no sistema.", @@ -35,14 +69,20 @@ describe("PATCH to /api/v1/users/[username]", () => { username: "user1", }); - await orchestrator.createUser({ + const createdUser2 = await orchestrator.createUser({ username: "user2", }); + const activatedUser2 = await orchestrator.activateUser(createdUser2); + const sessionObject2 = await orchestrator.createSession( + activatedUser2.id, + ); + const response = await fetch("http://localhost:3000/api/v1/users/user2", { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ username: "user1", @@ -62,29 +102,34 @@ describe("PATCH to /api/v1/users/[username]", () => { test("With duplicated 'email'", async () => { await orchestrator.createUser({ - email: "email1@gmail.com", + email: "email1@xedit.com", }); - const createUser2 = await orchestrator.createUser({ - email: "email2@gmail.com", + const createdUser2 = await orchestrator.createUser({ + email: "email2@xedit.com", }); + const activatedUser2 = await orchestrator.activateUser(createdUser2); + const sessionObject2 = await orchestrator.createSession( + activatedUser2.id, + ); + const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser2.username}`, + `http://localhost:3000/api/v1/users/${createdUser2.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ - email: "email1@gmail.com", + email: "email1@xedit.com", }), }, ); expect(response.status).toBe(400); const responseBody = await response.json(); - expect(responseBody).toEqual({ name: "ValidationError", message: "O email informado já está sendo utilizando.", @@ -94,9 +139,11 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With unique 'username'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ username: "uniqueUser1", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( "http://localhost:3000/api/v1/users/uniqueUser1", @@ -104,22 +151,21 @@ describe("PATCH to /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser2", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, username: "uniqueUser2", - email: createUser.email, - features: ["read:activation_token"], + email: createdUser.email, + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -133,32 +179,33 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With unique 'email'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ email: "uniqueEmail1@gmail.com", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser.username}`, + `http://localhost:3000/api/v1/users/${createdUser.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ email: "uniqueEmail2@gmail.com", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, - username: createUser.username, + username: createdUser.username, email: "uniqueEmail2@gmail.com", - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -172,32 +219,33 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With new 'password'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ password: "newPassword1", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser.username}`, + `http://localhost:3000/api/v1/users/${createdUser.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword2", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, - username: createUser.username, - email: createUser.email, - features: ["read:activation_token"], + username: createdUser.username, + email: createdUser.email, + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, @@ -209,14 +257,14 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody.update_at > responseBody.created_at).toBe(true); - const userInDatabase = await user.findOneByUsername(createUser.username); + const userInDatabase = await user.findOneByUsername(createdUser.username); const correctPasswordMatch = await password.compare( "newPassword2", userInDatabase.password, ); const incorrectPasswordMatch = await password.compare( - createUser.username, + createdUser.username, userInDatabase.password, ); From 71da16ab1e596c6c7f7dd9284a5832b0e11fdb69 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:08:10 +0000 Subject: [PATCH 14/22] feat: consider `resource` in `authorization` model --- infra/authorization.js | 10 +++++- pages/api/v1/users/[username]/index.js | 13 +++++++ .../api/v1/users/[username]/patch.test.js | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/infra/authorization.js b/infra/authorization.js index 943e461..54c4eb5 100644 --- a/infra/authorization.js +++ b/infra/authorization.js @@ -1,10 +1,18 @@ -function can(user, feature) { +function can(user, feature, resource) { let authorized = false; if (user.features.includes(feature)) { authorized = true; } + if (feature === "update:user" && resource) { + authorized = false; + + if (user.id === resource.id) { + authorized = true; + } + } + return authorized; } diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 8c62eaf..61bec2c 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -1,6 +1,8 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import authorization from "infra/authorization"; +import { ForbiddenError } from "infra/errors"; const router = createRouter(); @@ -21,6 +23,17 @@ async function patchHandler(request, response) { const username = request.query.username; const userInputValue = request.body; + const userTrtingToPatch = request.context.user; + const targetUser = await user.findOneByUsername(username); + + if (!authorization.can(userTrtingToPatch, "update:user", targetUser)) { + throw new ForbiddenError({ + message: "Você não possui permissão para atualizar outro usuário.", + action: + "Verifique se você possui a feature necessária para atualizar outro usuario.", + }); + } + const updatedUser = await user.update(username, userInputValue); return response.status(200).json(updatedUser); } diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 03b3780..70c769d 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -100,6 +100,42 @@ describe("PATCH to /api/v1/users/[username]", () => { }); }); + test("With `userB` targeting `userA`", async () => { + await orchestrator.createUser({ + username: "userA", + }); + + const createdUserB = await orchestrator.createUser({ + username: "userB", + }); + + const activatedUserB = await orchestrator.activateUser(createdUserB); + const sessionObjectB = await orchestrator.createSession( + activatedUserB.id, + ); + + const response = await fetch("http://localhost:3000/api/v1/users/userA", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObjectB.token}`, + }, + body: JSON.stringify({ + username: "userC", + }), + }); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + action: + "Verifique se você possui a feature necessária para atualizar outro usuario.", + message: "Você não possui permissão para atualizar outro usuário.", + name: "ForbiddenError", + statusCode: 403, + }); + }); + test("With duplicated 'email'", async () => { await orchestrator.createUser({ email: "email1@xedit.com", From da3bd4fb5a39bb5675dfc3f747db5bf680d46d58 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:44:34 +0000 Subject: [PATCH 15/22] feat: require `update:user` to acess `\api\v1\users\[username]` --- infra/authorization.js | 2 +- models/user.js | 31 ++++++++++++ .../api/v1/users/[username]/patch.test.js | 50 +++++++++++++++++++ tests/orchestrator.js | 6 +++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/infra/authorization.js b/infra/authorization.js index 54c4eb5..43f7ff9 100644 --- a/infra/authorization.js +++ b/infra/authorization.js @@ -8,7 +8,7 @@ function can(user, feature, resource) { if (feature === "update:user" && resource) { authorized = false; - if (user.id === resource.id) { + if (user.id === resource.id || can(user, "update:user:others")) { authorized = true; } } diff --git a/models/user.js b/models/user.js index 5da3ec7..e132ee9 100644 --- a/models/user.js +++ b/models/user.js @@ -253,10 +253,41 @@ async function setFeature(userId, features) { } } +async function addFeatures(userId, features) { + const updateUser = await runUpdateQuery(userId, features); + return updateUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = array_cat(features, $2), + update_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "Usuário não encontrado.", + action: "Verifique o identificador informado.", + }); + } + + return results.rows[0]; + } +} + const user = { create, update, setFeature, + addFeatures, findOneById, findOneByUsername, findOneByEmail, diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 70c769d..7473710 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -308,4 +308,54 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(incorrectPasswordMatch).toBe(false); }); }); + + describe("Privileged user", () => { + test("With `update:others` targeting `defaultUser`", async () => { + const privilegedUser = await orchestrator.createUser(); + const activatedPrivilegedUser = + await orchestrator.activateUser(privilegedUser); + + await orchestrator.addFeaturesToUser(privilegedUser, [ + "update:user:others", + ]); + + const privilegedUsersession = await orchestrator.createSession( + activatedPrivilegedUser.id, + ); + + const defaultUser = await orchestrator.createUser(); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${defaultUser.username}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${privilegedUsersession.token}`, + }, + body: JSON.stringify({ + username: "AlteradoPorPrivilegiado", + }), + }, + ); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: defaultUser.id, + username: "AlteradoPorPrivilegiado", + email: defaultUser.email, + features: defaultUser.features, + password: responseBody.password, + created_at: responseBody.created_at, + update_at: responseBody.update_at, + }); + + expect(uuidVersion(responseBody.id)).toEqual(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.update_at)).not.toBeNaN(); + + expect(responseBody.update_at > responseBody.created_at).toBe(true); + }); + }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 056e235..786d0e4 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -105,6 +105,11 @@ async function activateUser(inactiveUser) { return await activation.activateUserByUserId(inactiveUser.id); } +async function addFeaturesToUser(userObject, feature) { + const updateUser = await user.addFeatures(userObject.id, feature); + return updateUser; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -115,6 +120,7 @@ const orchestrator = { getLastEmail, extractUUID, activateUser, + addFeaturesToUser, }; export default orchestrator; From 356de9445beca90434559b98ff105d15124764d9 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:55:18 +0000 Subject: [PATCH 16/22] feat: created `filterOutput` for priveleged user --- infra/authorization.js | 60 +++++++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 10 +++- pages/api/v1/migrations/index.js | 16 ++++- pages/api/v1/sessions/index.js | 22 +++++-- pages/api/v1/user/index.js | 11 +++- pages/api/v1/users/[username]/index.js | 22 +++++-- pages/api/v1/users/index.js | 10 +++- .../_user-case/registration-flow.test.js | 2 - .../integration/api/v1/migrations/get.test.js | 55 ++++++++++++++++- .../api/v1/migrations/post.test.js | 60 +++++++++++-------- tests/integration/api/v1/user/get.test.js | 2 - .../api/v1/users/[username]/get.test.js | 6 -- .../api/v1/users/[username]/patch.test.js | 8 --- tests/integration/api/v1/users/post.test.js | 2 - 14 files changed, 225 insertions(+), 61 deletions(-) diff --git a/infra/authorization.js b/infra/authorization.js index 43f7ff9..90171bf 100644 --- a/infra/authorization.js +++ b/infra/authorization.js @@ -16,8 +16,68 @@ function can(user, feature, resource) { return authorized; } +function filterOutput(user, feature, insecureValues) { + if (feature === "read:user") { + return { + id: insecureValues.id, + username: insecureValues.username, + features: insecureValues.features, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + }; + } + + if (feature === "read:user:self") { + if (user.id === insecureValues.id) { + return { + id: insecureValues.id, + username: insecureValues.username, + email: insecureValues.email, + features: insecureValues.features, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + }; + } + } + + if (feature === "read:session") { + if (user.id === insecureValues.user_id) { + return { + id: insecureValues.id, + token: insecureValues.token, + user_id: insecureValues.user_id, + username: insecureValues.username, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + }; + } + } + + if (feature === "read:activation_token") { + return { + id: insecureValues.id, + user_id: insecureValues.user_id, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + expires_at: insecureValues.expires_at, + used_at: insecureValues.used_at, + }; + } + + if (feature === "read:migration") { + return insecureValues.map((migration) => { + return { + path: migration.path, + name: migration.name, + timestamp: migration.timestamp, + }; + }); + } +} + const authorization = { can, + filterOutput, }; export default authorization; diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js index 69e488a..e668f8f 100644 --- a/pages/api/v1/activations/[token_id]/index.js +++ b/pages/api/v1/activations/[token_id]/index.js @@ -1,6 +1,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import activation from "models/activation"; +import authorization from "infra/authorization"; const router = createRouter(); @@ -10,6 +11,7 @@ router.patch(controller.canRequest("read:activation_token"), patchHandler); export default router.handler(controller.errorHandlers); async function patchHandler(request, response) { + const userTryingToPatch = request.context.user; const activationTokenId = request.query.token_id; const validActivationToken = @@ -20,5 +22,11 @@ async function patchHandler(request, response) { const usedActivationToken = await activation.markTokenAsUsed(activationTokenId); - return response.status(200).json(usedActivationToken); + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:activation_token", + usedActivationToken, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/migrations/index.js b/pages/api/v1/migrations/index.js index 281066a..bab2389 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -1,17 +1,27 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import migrator from "models/migrator"; +import authorization from "infra/authorization"; const router = createRouter(); -router.get(getHandler); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:migration"), getHandler); +router.post(controller.canRequest("create:migrations"), postHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const pendingMigrations = await migrator.listPendingMigrations(); - return response.status(200).json(pendingMigrations); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user:self", + pendingMigrations, + ); + + return response.status(200).json(secureOutputValues); } async function postHandler(request, response) { diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index a1c79ed..9c0f64e 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -30,18 +30,30 @@ async function postHandler(request, response) { } const newSession = await session.create(authenticatedUser.id); - controller.setSessionCookie(newSession.token, response); - return response.status(201).json(newSession); + const secureOutputValues = authorization.filterOutput( + authenticatedUser, + "read:session", + newSession, + ); + + return response.status(201).json(secureOutputValues); } -async function deleteHandler(resquest, response) { - const sessionToken = resquest.cookies.session_id; +async function deleteHandler(request, response) { + const sessionToken = request.cookies.session_id; + const userTryingDelete = request.context.user; const sessionObject = await session.findOneValidByToken(sessionToken); const expiredSession = await session.expireById(sessionObject.id); controller.clearSessionCookie(response); - return response.status(200).json(expiredSession); + const secureOutputValues = authorization.filterOutput( + userTryingDelete, + "read:session", + expiredSession, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index 5c70647..b8b6eac 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -2,6 +2,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; import session from "models/session"; +import authorization from "infra/authorization"; const router = createRouter(); @@ -11,6 +12,7 @@ router.get(controller.canRequest("read:session"), getHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const sessionToken = request.cookies.session_id; const sessionObject = await session.findOneValidByToken(sessionToken); @@ -24,5 +26,12 @@ async function getHandler(request, response) { "Cache-Control", "no-store , max-age = 0, must-revalidate", ); - return response.status(200).json(userFound); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user:self", + userFound, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 61bec2c..25a4483 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -13,20 +13,27 @@ router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const username = request.query.username; const userFound = await user.findOneByUsername(username); - return response.status(200).json(userFound); + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user", + userFound, + ); + + return response.status(200).json(secureOutputValues); } async function patchHandler(request, response) { const username = request.query.username; const userInputValue = request.body; - const userTrtingToPatch = request.context.user; + const userTryingToPatch = request.context.user; const targetUser = await user.findOneByUsername(username); - if (!authorization.can(userTrtingToPatch, "update:user", targetUser)) { + if (!authorization.can(userTryingToPatch, "update:user", targetUser)) { throw new ForbiddenError({ message: "Você não possui permissão para atualizar outro usuário.", action: @@ -35,5 +42,12 @@ async function patchHandler(request, response) { } const updatedUser = await user.update(username, userInputValue); - return response.status(200).json(updatedUser); + + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:user", + updatedUser, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index 1d74174..59e054f 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -2,6 +2,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; import activation from "models/activation"; +import authorization from "infra/authorization"; const router = createRouter(); @@ -11,11 +12,18 @@ router.post(controller.canRequest("create:user"), postHandler); export default router.handler(controller.errorHandlers); async function postHandler(request, response) { + const userTryingToPost = request.context.user; const userInputValue = request.body; const newUser = await user.create(userInputValue); const activationToken = await activation.create(newUser.id); await activation.sendEmailToUser(newUser, activationToken); - return response.status(201).json(newUser); + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:user", + newUser, + ); + + return response.status(201).json(secureOutputValues); } diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js index 9213821..e0c1d80 100644 --- a/tests/integration/_user-case/registration-flow.test.js +++ b/tests/integration/_user-case/registration-flow.test.js @@ -36,9 +36,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, username: "RegistrationFlow", - email: "registraion.flow@xedit.com", features: ["read:activation_token"], - password: createUserResponseBody.password, created_at: createUserResponseBody.created_at, update_at: createUserResponseBody.update_at, }); diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index c9502c5..af6b0b8 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -3,18 +3,69 @@ import orchestrator from "../../../../orchestrator"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("GET to /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { - const response = await fetch("http://localhost:3000/api/v1/migrations"); + const response = await fetch("http://localhost:3000/api/v1/user"); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:session"`, + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { + test("Retrieving pending migrations", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "read:migration"', + statusCode: 403, + }); + }); + }); + + describe("Privileged user", () => { + test("With `read:migration`", async () => { + const createdUser = await orchestrator.createUser({}); + await orchestrator.addFeaturesToUser(createdUser, ["read:migration"]); + + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); const responseBody = await response.json(); expect(Array.isArray(responseBody)).toBe(true); - expect(responseBody.length).toBeGreaterThan(0); }); }); }); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 15f7575..4bcf219 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,39 +3,51 @@ import orchestrator from "../../../../orchestrator"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("POST to /api/v1/migrations", () => { describe("Anonymous user", () => { - describe("Running pending migrations", () => { - test("For the fisrt time", async () => { - const response1 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response1.status).toBe(201); - - const response1Body = await response1.json(); - expect(Array.isArray(response1Body)).toBe(true); - expect(response1Body.length).toBeGreaterThan(0); + test("Retrieving pending migrations", async () => { + const response = await fetch("http://localhost:3000/api/v1/user"); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:session"`, + statusCode: 403, }); + }); + }); - test("For the sercond time", async () => { - const response2 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response2.status).toBe(200); + describe("Default user", () => { + test("Retrieving pending migrations", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); - const response2Body = await response2.json(); + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(403); - expect(Array.isArray(response2Body)).toBe(true); - expect(response2Body.length).toBe(0); + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "read:migration"', + statusCode: 403, }); }); }); + + describe("Privileged user", () => { + test("With `read:migration`", async () => {}); + }); }); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 26b5c51..19e4821 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -53,7 +53,6 @@ describe("GET to /api/v1/user", () => { username: "UserWithValidSession", email: createUser.email, features: ["create:session", "read:session", "update:user"], - password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: activatedUser.update_at.toISOString(), }); @@ -226,7 +225,6 @@ describe("GET to /api/v1/user", () => { username: "UserWithSession", email: createUser.email, features: ["create:session", "read:session", "update:user"], - password: createUser.password, created_at: createUser.created_at.toISOString(), update_at: activatedUser.update_at.toISOString(), }); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 0d96ae7..a54a827 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -26,9 +26,7 @@ describe("GET to /api/v1/users/[username]", () => { expect(response2Body).toEqual({ id: response2Body.id, username: "MesmoCase", - email: "mesmo.case@gmail.com", features: ["read:activation_token"], - password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, }); @@ -41,8 +39,6 @@ describe("GET to /api/v1/users/[username]", () => { test("With case mismatch", async () => { await orchestrator.createUser({ username: "CaseDiferente", - email: "case.diferente@gmail.com", - password: "senha123", }); const response2 = await fetch( @@ -55,9 +51,7 @@ describe("GET to /api/v1/users/[username]", () => { expect(response2Body).toEqual({ id: response2Body.id, username: "CaseDiferente", - email: "case.diferente@gmail.com", features: ["read:activation_token"], - password: response2Body.password, created_at: response2Body.created_at, update_at: response2Body.update_at, }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 7473710..91ca1d9 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -200,9 +200,7 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "uniqueUser2", - email: createdUser.email, features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -240,9 +238,7 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: "uniqueEmail2@gmail.com", features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -280,9 +276,7 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: createdUser.email, features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -344,9 +338,7 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: defaultUser.id, username: "AlteradoPorPrivilegiado", - email: defaultUser.email, features: defaultUser.features, - password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 2ec7ebc..f1ac5b7 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,9 +31,7 @@ describe("POST to /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "guilhermeMarinho", - email: "guimars@gmail.com", features: ["read:activation_token"], - password: responseBody.password, created_at: responseBody.created_at, update_at: responseBody.update_at, }); From 8648fc1835ebb8da633db21449186de8c38f7274 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:08:20 +0000 Subject: [PATCH 17/22] feat: apply `authorization.filterOutput()` to all endpoints --- infra/authorization.js | 23 +++++- infra/controller.js | 7 +- pages/api/v1/migrations/index.js | 17 ++-- pages/api/v1/status/index.js | 17 +++- .../integration/api/v1/migrations/get.test.js | 7 +- .../api/v1/migrations/post.test.js | 18 ++++- .../api/v1/sessions/delete.test.js | 2 + tests/integration/api/v1/status/get.test.js | 77 ++++++++++++++++++- 8 files changed, 151 insertions(+), 17 deletions(-) diff --git a/infra/authorization.js b/infra/authorization.js index 90171bf..348afb0 100644 --- a/infra/authorization.js +++ b/infra/authorization.js @@ -47,8 +47,9 @@ function filterOutput(user, feature, insecureValues) { token: insecureValues.token, user_id: insecureValues.user_id, username: insecureValues.username, + expires_at: insecureValues.expires_at, created_at: insecureValues.created_at, - update_at: insecureValues.update_at, + updated_at: insecureValues.updated_at, }; } } @@ -73,6 +74,26 @@ function filterOutput(user, feature, insecureValues) { }; }); } + + if (feature === "read:status") { + const output = { + updated_at: insecureValues.updated_at, + dependencies: { + database: { + max_connections: insecureValues.dependencies.database.max_connections, + open_connections: + insecureValues.dependencies.database.open_connections, + }, + }, + }; + + if (user.features.includes("read:version")) { + output.dependencies.database.version = + insecureValues.dependencies.database.version; + } + + return output; + } } const authorization = { diff --git a/infra/controller.js b/infra/controller.js index 46f5fc8..af1a440 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -86,7 +86,12 @@ async function injectAutenticatedUser(request) { async function injectAnonymousUser(request) { const anonymousUserObject = { - features: ["read:activation_token", "create:session", "create:user"], + features: [ + "read:activation_token", + "read:status", + "create:session", + "create:user", + ], }; request.context = { diff --git a/pages/api/v1/migrations/index.js b/pages/api/v1/migrations/index.js index bab2389..7bc57a6 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -7,7 +7,7 @@ const router = createRouter(); router.use(controller.injectAnonymousOrUser); router.get(controller.canRequest("read:migration"), getHandler); -router.post(controller.canRequest("create:migrations"), postHandler); +router.post(controller.canRequest("create:migration"), postHandler); export default router.handler(controller.errorHandlers); @@ -17,7 +17,7 @@ async function getHandler(request, response) { const secureOutputValues = authorization.filterOutput( userTryingToGet, - "read:user:self", + "read:migration", pendingMigrations, ); @@ -25,11 +25,18 @@ async function getHandler(request, response) { } async function postHandler(request, response) { + const userTryingToPost = request.context.user; const migratedMigrations = await migrator.runPendingMigrations(); - if (migratedMigrations.length > 0) { - return response.status(201).json(migratedMigrations); + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:migration", + migratedMigrations, + ); + + if (secureOutputValues.length > 0) { + return response.status(201).json(secureOutputValues); } - return response.status(200).json(migratedMigrations); + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/status/index.js b/pages/api/v1/status/index.js index bccf2a1..627c1f5 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -2,21 +2,24 @@ import { createRouter } from "next-connect"; import database from "infra/database.js"; import controller from "infra/controller"; +import authorization from "infra/authorization"; const router = createRouter(); +router.use(controller.injectAnonymousOrUser); router.get(getHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const updatedAt = new Date().toISOString(); const databaseVersionResult = await database.query("SHOW server_version;"); const databaseVersionValue = databaseVersionResult.rows[0].server_version; const databaseMaxConnectionsResult = await database.query( - "SHOW max_connections", + "SHOW max_connections;", ); const databaseMaxConnectionsValue = @@ -30,7 +33,7 @@ async function getHandler(request, response) { const databaseOpenConnectionsValue = databaseOpenConnectionsResult.rows[0].count; - response.status(200).json({ + const status = { updated_at: updatedAt, dependencies: { database: { @@ -39,5 +42,13 @@ async function getHandler(request, response) { open_connections: databaseOpenConnectionsValue, }, }, - }); + }; + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:status", + status, + ); + + response.status(200).json(secureOutputValues); } diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index af6b0b8..fc30bc6 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -50,21 +50,18 @@ describe("GET to /api/v1/migrations", () => { describe("Privileged user", () => { test("With `read:migration`", async () => { const createdUser = await orchestrator.createUser({}); - await orchestrator.addFeaturesToUser(createdUser, ["read:migration"]); - const activatedUser = await orchestrator.activateUser(createdUser); - const sessionObject = await orchestrator.createSession(activatedUser); + await orchestrator.addFeaturesToUser(createdUser, ["read:migration"]); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch("http://localhost:3000/api/v1/migrations", { headers: { Cookie: `session_id=${sessionObject.token}`, }, }); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(Array.isArray(responseBody)).toBe(true); }); }); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 4bcf219..1f0a0fe 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -48,6 +48,22 @@ describe("POST to /api/v1/migrations", () => { }); describe("Privileged user", () => { - test("With `read:migration`", async () => {}); + test("With `create:migration`", async () => { + const createdUser = await orchestrator.createUser({}); + const activatedUser = await orchestrator.activateUser(createdUser); + await orchestrator.addFeaturesToUser(createdUser, ["create:migration"]); + const sessionObject = await orchestrator.createSession(activatedUser.id); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(Array.isArray(responseBody)).toBe(true); + }); }); }); diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index b848cd4..ecd1b5c 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -92,6 +92,8 @@ describe("DELETE to /api/v1/sessions", () => { updated_at: responseBody.updated_at, }); + console.log(responseBody); + expect(uuidVersion(responseBody.id)).toEqual(4); expect(Date.parse(responseBody.expires_at)).not.toBeNaN(); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); diff --git a/tests/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js index 30623b3..d7d2339 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -6,18 +6,93 @@ beforeAll(async () => { describe("GET to /api/v1/status", () => { describe("Anonymous user", () => { - test("Rertieving current system status", async () => { + test("Retrieving current system status", async () => { const response = await fetch("http://localhost:3000/api/v1/status"); expect(response.status).toBe(200); const responseBody = await response.json(); + const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); + expect(responseBody.updated_at).toEqual(parsedUpdateAt); + + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); + }); + }); + + describe("Default use user", () => { + test("Retrieving current system status", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + + const response = await fetch("http://localhost:3000/api/v1/status"); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); + + expect(responseBody.updated_at).toEqual(parsedUpdateAt); + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); + }); + }); + describe("Privileged user", () => { + test("With `read:version`", async () => { + const createdUser = await orchestrator.createUser({}); + const activatedUser = await orchestrator.activateUser(createdUser); + + await orchestrator.addFeaturesToUser(activatedUser, [ + "read:status", + "read:version", + ]); + + const sessionObject = await orchestrator.createSession(activatedUser.id); + + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); expect(responseBody.updated_at).toEqual(parsedUpdateAt); expect(responseBody.dependencies.database.version).toEqual("16.13"); expect(responseBody.dependencies.database.max_connections).toEqual(100); expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + version: responseBody.dependencies.database.version, + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); }); }); }); From 19304cc1fcb5fa7e431ccd1f60505ff0ecac95f5 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:06:37 +0000 Subject: [PATCH 18/22] feat: valiade `user`, `feature` and `resource` in `authorization` model --- infra/authorization.js | 61 ++++++- .../integration/api/v1/migrations/get.test.js | 4 +- .../api/v1/migrations/post.test.js | 4 +- .../api/v1/sessions/delete.test.js | 4 - tests/integration/api/v1/status/get.test.js | 4 +- tests/unit/models/authorization.test.js | 150 ++++++++++++++++++ 6 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 tests/unit/models/authorization.test.js diff --git a/infra/authorization.js b/infra/authorization.js index 348afb0..e47a485 100644 --- a/infra/authorization.js +++ b/infra/authorization.js @@ -1,4 +1,33 @@ +import { InternalServerError } from "./errors"; + +const avalableFeatures = [ + // USER + "create:user", + "read:user", + "read:user:self", + "update:user", + "update:user:others", + + // SESSIONS + "create:session", + "read:session", + + // ACTIVATION_TOKEN + "read:activation_token", + + // MIGRATIONS + "create:migration", + "read:migration", + + // STATUS + "read:status", + "read:status:version", +]; + function can(user, feature, resource) { + validateUser(user); + validateFeature(feature); + let authorized = false; if (user.features.includes(feature)) { @@ -17,6 +46,10 @@ function can(user, feature, resource) { } function filterOutput(user, feature, insecureValues) { + validateUser(user); + validateFeature(feature); + validateResource(insecureValues); + if (feature === "read:user") { return { id: insecureValues.id, @@ -87,7 +120,7 @@ function filterOutput(user, feature, insecureValues) { }, }; - if (user.features.includes("read:version")) { + if (user.features.includes("read:status:version")) { output.dependencies.database.version = insecureValues.dependencies.database.version; } @@ -96,6 +129,32 @@ function filterOutput(user, feature, insecureValues) { } } +function validateUser(user) { + if (!user || !user.features) { + throw new InternalServerError({ + cause: "É necessario fornecer `user` no model `authorization`.", + }); + } +} + +function validateFeature(feature) { + if (!feature || !avalableFeatures.includes(feature)) { + throw new InternalServerError({ + cause: + "É necessario fornecer `feature` conhecida no model `authorization`.", + }); + } +} + +function validateResource(resource) { + if (!resource) { + throw new InternalServerError({ + cause: + "É necessario fornecer `resource`em `authorization.filterOutput()`.", + }); + } +} + const authorization = { can, filterOutput, diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index fc30bc6..e8582de 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -9,7 +9,7 @@ beforeAll(async () => { describe("GET to /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { - const response = await fetch("http://localhost:3000/api/v1/user"); + const response = await fetch("http://localhost:3000/api/v1/migrations"); expect(response.status).toBe(403); const responseBody = await response.json(); @@ -17,7 +17,7 @@ describe("GET to /api/v1/migrations", () => { expect(responseBody).toEqual({ name: "ForbiddenError", message: "Você não possui permissão para executar esta ação.", - action: `Verifique se o seu usuário possui a feature "read:session"`, + action: `Verifique se o seu usuário possui a feature "read:migration"`, statusCode: 403, }); }); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 1f0a0fe..ab501b6 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -9,7 +9,7 @@ beforeAll(async () => { describe("POST to /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { - const response = await fetch("http://localhost:3000/api/v1/user"); + const response = await fetch("http://localhost:3000/api/v1/migrations"); expect(response.status).toBe(403); const responseBody = await response.json(); @@ -17,7 +17,7 @@ describe("POST to /api/v1/migrations", () => { expect(responseBody).toEqual({ name: "ForbiddenError", message: "Você não possui permissão para executar esta ação.", - action: `Verifique se o seu usuário possui a feature "read:session"`, + action: `Verifique se o seu usuário possui a feature "read:migration"`, statusCode: 403, }); }); diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index ecd1b5c..8620fd3 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -78,8 +78,6 @@ describe("DELETE to /api/v1/sessions", () => { Cookie: `session_id=${sessionObject.token}`, }, }); - - console.log(response.status); expect(response.status).toBe(200); const responseBody = await response.json(); @@ -92,8 +90,6 @@ describe("DELETE to /api/v1/sessions", () => { updated_at: responseBody.updated_at, }); - console.log(responseBody); - expect(uuidVersion(responseBody.id)).toEqual(4); expect(Date.parse(responseBody.expires_at)).not.toBeNaN(); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); diff --git a/tests/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js index d7d2339..7a4444f 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -57,13 +57,13 @@ describe("GET to /api/v1/status", () => { }); describe("Privileged user", () => { - test("With `read:version`", async () => { + test("With `read:status:version`", async () => { const createdUser = await orchestrator.createUser({}); const activatedUser = await orchestrator.activateUser(createdUser); await orchestrator.addFeaturesToUser(activatedUser, [ "read:status", - "read:version", + "read:status:version", ]); const sessionObject = await orchestrator.createSession(activatedUser.id); diff --git a/tests/unit/models/authorization.test.js b/tests/unit/models/authorization.test.js new file mode 100644 index 0000000..4ec42b3 --- /dev/null +++ b/tests/unit/models/authorization.test.js @@ -0,0 +1,150 @@ +import authorization from "infra/authorization"; +import { InternalServerError } from "infra/errors"; + +describe("models/authorization.js", () => { + describe(".can()", () => { + test("without `user`", () => { + expect(() => { + authorization.can(); + }).toThrow(InternalServerError); + }); + + test("without `user.features`", () => { + const createdUser = { + username: "UserWithoutFeatures", + }; + + expect(() => { + authorization.can(createdUser); + }).toThrow(InternalServerError); + }); + + test("with unknown `feature`", () => { + const createdUser = { + features: [], + }; + + expect(() => { + authorization.can(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with unknown `user`", () => { + const createdUser = { + username: "", + }; + + expect(() => { + authorization.can(createdUser, ""); + }).toThrow(InternalServerError); + }); + + test("with unknown `user` and `feature`", () => { + const createdUser = { + username: "", + features: [], + }; + + expect(() => { + authorization.can(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with valid `user` and know `feature`", () => { + const createdUser = { + features: ["read:status"], + }; + + expect(authorization.can(createdUser, "read:status")).toBe(true); + }); + }); + + describe(".filterOutput()", () => { + test("without `user`", () => { + expect(() => { + authorization.filterOutput(); + }).toThrow(InternalServerError); + }); + + test("without `user.features`", () => { + const createdUser = { + username: "UserWithoutFeatures", + }; + + expect(() => { + authorization.filterOutput(createdUser); + }).toThrow(InternalServerError); + }); + + test("with unknown `feature`", () => { + const createdUser = { + features: [], + }; + + expect(() => { + authorization.filterOutput(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with unknown `user`", () => { + const createdUser = { + username: "", + }; + + expect(() => { + authorization.filterOutput(createdUser, ""); + }).toThrow(InternalServerError); + }); + + test("with unknown `user` and `feature`", () => { + const createdUser = { + username: "", + features: [], + }; + + expect(() => { + authorization.filterOutput(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with valid `user`, know `feature` but no `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + expect(() => { + authorization.filterOutput(createdUser, "read:user"); + }).toThrow(InternalServerError); + }); + + test("with valid `user`, know `feature` and `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + const resource = { + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + update_at: "2026-0101T00:00:00.000Z", + email: "resource@gmail.com", + password: "resource", + }; + + const result = authorization.filterOutput( + createdUser, + "read:user", + resource, + ); + + expect(result).toEqual({ + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + update_at: "2026-0101T00:00:00.000Z", + }); + }); + }); +}); From d8fdbd9f20be054ef566158b375a3a76e599ec78 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:46:15 +0000 Subject: [PATCH 19/22] chore: update Node.js version to 24 --- .nvmrc | 2 +- package-lock.json | 3 +++ package.json | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index a77793e..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/hydrogen +24 diff --git a/package-lock.json b/package-lock.json index 686108c..aa748d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,9 @@ "npm-run-all": "^4.1.5", "prettier": "3.8.3", "set-cookie-parser": "2.7.1" + }, + "engines": { + "node": "24" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index d37db71..20b3021 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "engines": { + "node": "24" } } From 3690aee334a54d48e21701b450ba61530ff6543b Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:46:58 +0000 Subject: [PATCH 20/22] ci: align Node.js version with `package.json` --- .github/workflows/linting.yaml | 6 +++--- .github/workflows/test.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index ec50013..671d946 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" cache: "npm" - run: npm ci @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" - run: npm ci @@ -45,7 +45,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" cache: "npm" - run: npm ci diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 19062f7..5225fb7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "24.14.0" + node-version-file: "package.json" - run: npm ci From 80800d227b528b42a51ec515900394805ba00410 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:08:16 +0000 Subject: [PATCH 21/22] chore: add `migrations:up:dry` npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 20b3021..019b189 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "services:wait:database": "node infra/scripts/wait-for-postgres.js", "migrations:create": "node-pg-migrate -m infra/migrations create", "migrations:up": "node-pg-migrate -m infra/migrations --envPath .env.development up", + "migrations:up:dry": "node-pg-migrate -m infra/migrations --envPath .env.development --dry-run up", "lint:prettier:check": "prettier --check .", "lint:prettier:fix": "prettier --write .", "lint:eslint:check": "eslint .", From df5f96bddcecf9d8ca2981f98880022d6fdfc711 Mon Sep 17 00:00:00 2001 From: Guilherme Marinho Santos <131541140+GuiMRDS@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:00:01 +0000 Subject: [PATCH 22/22] refactor: improve error loggin in `email.send()` --- infra/email.js | 12 +++++++++++- infra/errors.js | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/infra/email.js b/infra/email.js index 13d7883..da99224 100644 --- a/infra/email.js +++ b/infra/email.js @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { ServiceError } from "./errors"; const transporter = nodemailer.createTransport({ host: process.env.EMAIL_SMTP_HOST, @@ -11,7 +12,16 @@ const transporter = nodemailer.createTransport({ }); async function send(mailOptions) { - await transporter.sendMail(mailOptions); + try { + await transporter.sendMail(mailOptions); + } catch (error) { + throw new ServiceError({ + message: "Não foi possivel enviar o email.", + action: "Verifique se o serviço de email está disponivel.", + cause: error, + context: mailOptions, + }); + } } const email = { diff --git a/infra/errors.js b/infra/errors.js index b9f5965..edbf2e4 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -19,13 +19,14 @@ export class InternalServerError extends Error { } export class ServiceError extends Error { - constructor({ cause, message }) { + constructor({ cause, message, action, context }) { super(message || "Serviço indisponível no momento.", { cause, }); this.name = "InternalServerError"; - this.action = "Entre em contato com o suporte"; + this.action = action || "Entre em contato com o suporte"; this.statusCode = 503; + this.context = context; } toJSON() { @@ -34,6 +35,7 @@ export class ServiceError extends Error { message: this.message, action: this.action, statusCode: this.statusCode, + context: this.context, }; } }