From bcb65429ffe76c3d081bc1cd2b67d88cced6abbc Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 3 Oct 2025 01:16:46 +0000 Subject: [PATCH 01/20] feat: add `features` column to `users` table --- .../migrations/1758481611584_add-features-to-users.js | 11 +++++++++++ tests/integration/api/v1/user/get.test.js | 1 + 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, 18 insertions(+) create mode 100644 infra/migrations/1758481611584_add-features-to-users.js diff --git a/infra/migrations/1758481611584_add-features-to-users.js b/infra/migrations/1758481611584_add-features-to-users.js new file mode 100644 index 0000000..e68f29b --- /dev/null +++ b/infra/migrations/1758481611584_add-features-to-users.js @@ -0,0 +1,11 @@ +exports.up = (pgm) => { + pgm.addColumn("users", { + features: { + type: "varchar[]", + nutNull: 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 2cd66c2..c8145d1 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -38,6 +38,7 @@ describe("GET /api/v1/user", () => { username: "UserWithValidSession", email: createdUser.email, password: createdUser.password, + features: [], created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_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 cf7cb7c..9a6537b 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 /api/v1/users/[username]", () => { username: "MesmoCase", email: createdUser.email, password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -54,6 +55,7 @@ describe("GET /api/v1/users/[username]", () => { username: "CaseDiferente", email: createdUser.email, password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index bc9157a..a2c96f2 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -121,6 +121,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: "uniqueUser2", email: createdUser.email, password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -157,6 +158,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: "uniqueEmail2@email.com", password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -195,6 +197,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: createdUser.email, password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index e4ee0cc..bc1a833 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -33,6 +33,7 @@ describe("POST /api/v1/users", () => { username: "alannub", email: "alannub@email.com", password: responseBody.password, + features: [], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); From a66ad6d206b6426fb4c9c549e17a8836404ae399 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 3 Oct 2025 21:05:46 +0000 Subject: [PATCH 02/20] feat: add default feature `read:activation_token` when creating `user` --- models/user.js | 10 +++- .../_use-cases/registration-flow.test.js | 54 +++++++++++++++++++ tests/integration/api/v1/user/get.test.js | 2 +- .../api/v1/users/[username]/get.test.js | 4 +- .../api/v1/users/[username]/patch.test.js | 6 +-- tests/integration/api/v1/users/post.test.js | 2 +- 6 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 tests/integration/_use-cases/registration-flow.test.js diff --git a/models/user.js b/models/user.js index cbb296c..22b4a0b 100644 --- a/models/user.js +++ b/models/user.js @@ -99,6 +99,7 @@ async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); await hashPasswordInObject(userInputValues); + injectDefaultFeaturesInObject(userInputValues); const newUser = await runInsertQuery(userInputValues); return newUser; @@ -107,9 +108,9 @@ async function create(userInputValues) { const results = 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,11 +118,16 @@ async function create(userInputValues) { userInputValues.username, userInputValues.email, userInputValues.password, + userInputValues.features, ], }); return results.rows[0]; } + + function injectDefaultFeaturesInObject(userInputValues) { + userInputValues.features = ["read:activation_token"]; + } } async function update(username, userInputValues) { diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js new file mode 100644 index 0000000..3ba2652 --- /dev/null +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -0,0 +1,54 @@ +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 successfull)", () => { + 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: "registration.flow@email.com.br", + password: "RegistrationFlowPassword" + }), + }); + + expect(createUserResponse.status).toBe(201); + + const createUserResponseBody = await createUserResponse.json(); + + expect(createUserResponseBody).toEqual({ + id: createUserResponseBody.id, + username: "RegistrationFlow", + email: "registration.flow@email.com.br", + password: createUserResponseBody.password, + features: ["read:activation_token"], + created_at: createUserResponseBody.created_at, + updated_at: createUserResponseBody.updated_at, + }); + }); + + test("Recive activation email", async () => { + + }); + + test("Activate account", async () => { + + }); + + test("Login", async () => { + + }); + + test("Get user information", async () => { + + }); +}) \ No newline at end of file diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index c8145d1..5c9cd2b 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -38,7 +38,7 @@ describe("GET /api/v1/user", () => { username: "UserWithValidSession", email: createdUser.email, password: createdUser.password, - features: [], + features: ["read:activation_token"], created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_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 9a6537b..5a72fd3 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 /api/v1/users/[username]", () => { username: "MesmoCase", email: createdUser.email, password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -55,7 +55,7 @@ describe("GET /api/v1/users/[username]", () => { username: "CaseDiferente", email: createdUser.email, password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index a2c96f2..b6f46f3 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -121,7 +121,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: "uniqueUser2", email: createdUser.email, password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -158,7 +158,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: "uniqueEmail2@email.com", password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -197,7 +197,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: createdUser.email, password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index bc1a833..1a65058 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -33,7 +33,7 @@ describe("POST /api/v1/users", () => { username: "alannub", email: "alannub@email.com", password: responseBody.password, - features: [], + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); From 7adf4ca0d9eb8aa6c1f3707352bc0ed06d3c4db0 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Thu, 16 Oct 2025 00:13:55 +0000 Subject: [PATCH 03/20] fix: handle empty email list in `orchestrator.getLastEmail()` --- tests/orchestrator.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/orchestrator.js b/tests/orchestrator.js index fa3a65b..ac9c82e 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -73,6 +73,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 feef23f08648d25dbebea0df2df82980584ebeb7 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Thu, 16 Oct 2025 00:15:18 +0000 Subject: [PATCH 04/20] feat: send activation email after `user` registration --- ...569579335_create-user-activation-tokens.js | 39 ++++++++++ infra/webserver.js | 17 +++++ models/activation.js | 73 +++++++++++++++++++ pages/api/v1/users/index.js | 5 ++ .../_use-cases/registration-flow.test.js | 13 +++- 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 infra/migrations/1760569579335_create-user-activation-tokens.js create mode 100644 infra/webserver.js create mode 100644 models/activation.js diff --git a/infra/migrations/1760569579335_create-user-activation-tokens.js b/infra/migrations/1760569579335_create-user-activation-tokens.js new file mode 100644 index 0000000..bfdab12 --- /dev/null +++ b/infra/migrations/1760569579335_create-user-activation-tokens.js @@ -0,0 +1,39 @@ +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())"), + }, + }) +}; + +exports.down = false; + diff --git a/infra/webserver.js b/infra/webserver.js new file mode 100644 index 0000000..a251cc0 --- /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 `https://${process.env.VERCEL_URL}` + } + + return "https://alannub.com.br" +} + +const webserver = { + origin: getOrigin(), +} + +export default webserver; \ No newline at end of file diff --git a/models/activation.js b/models/activation.js new file mode 100644 index 0000000..713d435 --- /dev/null +++ b/models/activation.js @@ -0,0 +1,73 @@ +import email from "infra/email.js" +import database from "infra/database.js"; +import webserver from "infra/webserver.js"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes + +async function findOneByUserId(userId) { + const newToken = await runSelectQuery(userId); + return newToken; + + async function runSelectQuery(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 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 sendEmailToUser(user, activationToken) { + await email.send({ + from: "AlanNub ", + to: user.email, + subject: "Ative seu cadastro no Fintab!", + text: `${user.username}, clique no link abaixo para ativar seu cadastro no Fintab: + +${webserver.origin}/cadastro/ativar/${activationToken.id} + +Atenciosamente, +Equipe FinTab`, + }); +}; + +const activation = { + findOneByUserId, + create, + sendEmailToUser, +}; + +export default activation; \ No newline at end of file diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index ce44816..068fb39 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.js"; import user from "models/user.js"; +import activation from "models/activation.js"; const router = createRouter(); @@ -11,5 +12,9 @@ export default router.handler(controller.errorHandlers); async function postHandler(request, response) { const userInputValues = request.body; const newUser = await user.create(userInputValues); + + const activationToken = await activation.create(newUser.id) + await activation.sendEmailToUser(newUser, activationToken); + return response.status(201).json(newUser); } diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 3ba2652..cd3ccfb 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,3 +1,4 @@ +import activation from "models/activation"; import orchestrator from "tests/orchestrator"; beforeAll(async () => { @@ -8,6 +9,8 @@ beforeAll(async () => { }); describe("Use case: Registration Flow (all successfull)", () => { + let createUserResponseBody; + test("Create user account", async () => { const createUserResponse = await fetch("http://localhost:3000/api/v1/users", { method: "POST", @@ -23,7 +26,7 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(createUserResponse.status).toBe(201); - const createUserResponseBody = await createUserResponse.json(); + createUserResponseBody = await createUserResponse.json(); expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, @@ -37,7 +40,15 @@ describe("Use case: Registration Flow (all successfull)", () => { }); test("Recive activation email", async () => { + const lastEmail = await orchestrator.getLastEmail(); + + const activationToken = await activation.findOneByUserId(createUserResponseBody.id); + expect(lastEmail.sender).toBe(""); + expect(lastEmail.recipients[0]).toBe(""); + expect(lastEmail.subject).toBe("Ative seu cadastro no Fintab!"); + expect(lastEmail.text).toContain("RegistrationFlow") + expect(lastEmail.text).toContain(activationToken.id) }); test("Activate account", async () => { From 90b68350f60d363f4d34ae64b261788d7c5e6f2e Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Thu, 23 Oct 2025 00:31:16 +0000 Subject: [PATCH 05/20] feat: add `activation.findOneValidById()` and `orchestrator.extractUUID()` --- models/activation.js | 25 +++++++++++++------ .../_use-cases/registration-flow.test.js | 17 ++++++++++--- tests/orchestrator.js | 6 +++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/models/activation.js b/models/activation.js index 713d435..24d3004 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,14 +1,16 @@ import email from "infra/email.js" import database from "infra/database.js"; import webserver from "infra/webserver.js"; +import { NotFoundError } from "infra/errors"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes -async function findOneByUserId(userId) { - const newToken = await runSelectQuery(userId); - return newToken; +async function findOneValidById(tokenId) { + const activationTokenObject = await runSelectQuery(tokenId); + + return activationTokenObject; - async function runSelectQuery(userId) { + async function runSelectQuery(tokenId) { const results = await database.query({ text: ` SELECT @@ -16,13 +18,22 @@ async function findOneByUserId(userId) { FROM user_activation_tokens WHERE - user_id = $1 + id = $1 + AND expires_at > NOW() + AND used_at IS NULL LIMIT 1 ;`, - values: [userId] + 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]; } } @@ -65,7 +76,7 @@ Equipe FinTab`, }; const activation = { - findOneByUserId, + findOneValidById, create, sendEmailToUser, }; diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index cd3ccfb..4177997 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,3 +1,4 @@ +import webserver from "infra/webserver"; import activation from "models/activation"; import orchestrator from "tests/orchestrator"; @@ -42,13 +43,21 @@ describe("Use case: Registration Flow (all successfull)", () => { test("Recive activation email", async () => { const lastEmail = await orchestrator.getLastEmail(); - const activationToken = await activation.findOneByUserId(createUserResponseBody.id); - expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Ative seu cadastro no Fintab!"); - expect(lastEmail.text).toContain("RegistrationFlow") - expect(lastEmail.text).toContain(activationToken.id) + expect(lastEmail.text).toContain("RegistrationFlow"); + + 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 ac9c82e..2146b9f 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -86,6 +86,11 @@ async function getLastEmail() { return lastEmailItem; } +function extractUUID(text) { + const math = text.match(/[0-9a-fA-F-]{36}/); + return math ? math[0] : null; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -94,6 +99,7 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, }; export default orchestrator; From 4cca8358fe48f0523bd40ac0cfd4aa17fbcdc056 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Thu, 23 Oct 2025 01:52:09 +0000 Subject: [PATCH 06/20] feat: add `PATCH` into `api/v1/activations/[token_id]` --- models/activation.js | 32 +++++++++++++++++++ models/user.js | 25 +++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 20 ++++++++++++ .../_use-cases/registration-flow.test.js | 24 +++++++++++--- 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 pages/api/v1/activations/[token_id]/index.js diff --git a/models/activation.js b/models/activation.js index 24d3004..5f6870a 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,6 +1,7 @@ import email from "infra/email.js" import database from "infra/database.js"; import webserver from "infra/webserver.js"; +import user from "models/user.js" import { NotFoundError } from "infra/errors"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -61,6 +62,35 @@ async function create(userId) { } } +async function markTokenAsUsed(activationTokenId) { + const usedActivationToken = await runUpdateQuery(activationTokenId); + return usedActivationToken; + + 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.setFeatures(userId, ["create:session"]); + return activatedUser; +}; + async function sendEmailToUser(user, activationToken) { await email.send({ from: "AlanNub ", @@ -78,6 +108,8 @@ Equipe FinTab`, const activation = { findOneValidById, create, + markTokenAsUsed, + activateUserByUserId, sendEmailToUser, }; diff --git a/models/user.js b/models/user.js index 22b4a0b..eb08b7c 100644 --- a/models/user.js +++ b/models/user.js @@ -224,12 +224,37 @@ async function hashPasswordInObject(userInputValues) { userInputValues.password = hashedPassword; } +async function setFeatures(userId, features) { + const updatedUser = await runUpdateQuery(userId, features); + return updatedUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = $2, + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + + return results.rows[0]; + } +} + const user = { create, findOneById, findOneByUsername, findOneByEmail, update, + setFeatures, }; 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..89d54f3 --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,20 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.js"; +import activation from "models/activation.js"; + +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); +} diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 4177997..10935a9 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,6 +1,7 @@ -import webserver from "infra/webserver"; -import activation from "models/activation"; -import orchestrator from "tests/orchestrator"; +import webserver from "infra/webserver.js"; +import activation from "models/activation.js"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js" beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -11,6 +12,7 @@ beforeAll(async () => { describe("Use case: Registration Flow (all successfull)", () => { let createUserResponseBody; + let activationTokenId; test("Create user account", async () => { const createUserResponse = await fetch("http://localhost:3000/api/v1/users", { @@ -48,7 +50,7 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(lastEmail.subject).toBe("Ative seu cadastro no Fintab!"); 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}`, @@ -61,7 +63,21 @@ describe("Use case: Registration Flow (all successfull)", () => { }); 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"]); }); test("Login", async () => { From e3da54ff05f27faf358340e7f324532f6cd36525 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Wed, 29 Oct 2025 21:56:32 +0000 Subject: [PATCH 07/20] feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/sessions` --- infra/controller.js | 54 ++++++++++++++++++- infra/errors.js | 20 +++++++ pages/api/v1/sessions/index.js | 3 +- .../_use-cases/registration-flow.test.js | 18 +++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index d619e38..e1cfa2c 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,5 +1,6 @@ import * as cookie from "cookie"; import session from "models/session.js"; +import user from "models/user.js"; import { InternalServerError, @@ -7,6 +8,7 @@ import { ValidationError, NotFoundError, UnauthorizedError, + ForbiddenError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -16,7 +18,7 @@ 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); } @@ -56,6 +58,54 @@ async function clearSessionCookie(response) { response.setHeader("Set-Cookie", setCookie); } +async function injectAnonymousOrUser(request, response, next) { + if (request.cookies?.session_id) { + await injectAuthenticatedUser(request); + return next(); + } + + injectAnonymousUser(request); + return next(); +} + +async function injectAuthenticatedUser(request) { + const sessionToken = request.cookies.session_id; + const sessionObject = session.findOneValidByToken(sessionToken); + const userObject = await user.findOneById(sessionObject.user_id); + + request.context = { + ...request.context, + user: userObject, + }; +} + +function injectAnonymousUser(request) { + const anonymousObject = { + features: ["read:activation_token", "create:session", "create:user"], + }; + + request.context = { + ...request.context, + user: anonymousObject, + }; +} + +function canRequest(feature) { + + return (request, response, next) => { + const useTryingToRequest = request.context.user; + + if (useTryingToRequest.features.includes(feature)) { + return next(); + } + + throw new ForbiddenError({ + message: "Você não possui permissão para executar essa ação.", + action: `Verifique se o seu usuário possui a feature "${feature}"`, + }); + } +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, @@ -63,6 +113,8 @@ const controller = { }, setSessionCookie, clearSessionCookie, + injectAnonymousOrUser, + canRequest, }; export default controller; diff --git a/infra/errors.js b/infra/errors.js index d4b754f..3943d44 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -117,3 +117,23 @@ export class MethodNotAllowedError 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, + status_code: this.statusCode, + }; + } +} \ No newline at end of file diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 2ee34b4..944c97e 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -5,7 +5,8 @@ import session from "models/session.js"; 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); diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 10935a9..f354d92 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -81,7 +81,25 @@ describe("Use case: Registration Flow (all successfull)", () => { }); test("Login", async () => { + const createSessionResponse = await fetch( + "http://localhost:3000/api/v1/sessions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "registration.flow@email.com.br", + password: "RegistrationFlowPassword", + }), + }, + ); + + expect(createSessionResponse.status).toBe(201); + + const createSessionResponseBody = await createSessionResponse.json(); + expect(createSessionResponseBody.user_id).toBe(createUserResponseBody.id) }); test("Get user information", async () => { From 19c60dc30a8ee103623d0c873fe8306ba7801b02 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Sat, 8 Nov 2025 01:15:17 +0000 Subject: [PATCH 08/20] feat: create `Authorization` model and use it inside `/sessions` controllers --- infra/controller.js | 3 ++- models/authorization.js | 15 +++++++++++++++ pages/api/v1/sessions/index.js | 10 ++++++++++ tests/integration/api/v1/sessions/post.test.js | 2 ++ tests/orchestrator.js | 6 ++++++ 5 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 models/authorization.js diff --git a/infra/controller.js b/infra/controller.js index e1cfa2c..2b3dc8f 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,6 +1,7 @@ import * as cookie from "cookie"; import session from "models/session.js"; import user from "models/user.js"; +import authorization from "models/authorization.js"; import { InternalServerError, @@ -95,7 +96,7 @@ function canRequest(feature) { return (request, response, next) => { const useTryingToRequest = request.context.user; - if (useTryingToRequest.features.includes(feature)) { + if (authorization.can(useTryingToRequest, feature)) { return next(); } diff --git a/models/authorization.js b/models/authorization.js new file mode 100644 index 0000000..469435a --- /dev/null +++ b/models/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; \ No newline at end of file diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 944c97e..ca68d8b 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,8 +1,11 @@ import { createRouter } from "next-connect"; import controller from "infra/controller.js"; import authentication from "models/authentication.js"; +import authorization from "models/authorization.js"; import session from "models/session.js"; +import { ForbiddenError } from "infra/errors.js"; + const router = createRouter(); router.use(controller.injectAnonymousOrUser); @@ -19,6 +22,13 @@ async function postHandler(request, response) { userInputValues.password, ); + if (!authorization.can(authenticatedUser, "create:session")) { + throw new ForbiddenError({ + message: "Você não possui permissão para fazer login", + action: "Contate o suporte caso você acredite que isto seja um erro." + }) + } + const newSession = await session.create(authenticatedUser.id); controller.setSessionCookie(newSession.token, response); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 47bdd4e..4a447fb 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -99,6 +99,8 @@ describe("POST /api/v1/sessions", () => { password: "tudocorreto", }); + await orchestrator.activateUser(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 2146b9f..994a5b6 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -5,6 +5,7 @@ import database from "infra/database.js"; import migrator from "models/migrator.js"; import user from "models/user.js"; import session from "models/session"; +import activation from "models/activation.js"; const emailHttpUrl = `http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}`; @@ -91,6 +92,10 @@ function extractUUID(text) { return math ? math[0] : null; } +async function activatedUser(inactiveUser) { + return await activation.activateUserByUserId(inactiveUser.id); +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -100,6 +105,7 @@ const orchestrator = { deleteAllEmails, getLastEmail, extractUUID, + activatedUser, }; export default orchestrator; From 46b313c5909fcdee25a4361349d37c5a30d5a235 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Wed, 12 Nov 2025 21:19:18 +0000 Subject: [PATCH 09/20] feat: require `read:session` to access `/user` endpoint --- models/activation.js | 22 +- pages/api/v1/activations/[token_id]/index.js | 7 +- pages/api/v1/user/index.js | 11 +- pages/api/v1/users/index.js | 3 +- .../_use-cases/registration-flow.test.js | 21 +- .../v1/activations/[token_id]/patch.test.js | 199 ++++++++++++++++++ tests/integration/api/v1/user/get.test.js | 89 +++++++- tests/integration/api/v1/users/post.test.js | 32 +++ tests/orchestrator.js | 4 +- 9 files changed, 367 insertions(+), 21 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 5f6870a..72eeb8a 100644 --- a/models/activation.js +++ b/models/activation.js @@ -2,7 +2,8 @@ import email from "infra/email.js" import database from "infra/database.js"; import webserver from "infra/webserver.js"; import user from "models/user.js" -import { NotFoundError } from "infra/errors"; +import { ForbiddenError, NotFoundError } from "infra/errors.js"; +import authorization from "models/authorization.js"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -87,9 +88,21 @@ async function markTokenAsUsed(activationTokenId) { }; async function activateUserByUserId(userId) { - const activatedUser = await user.setFeatures(userId, ["create:session"]); + const userToActivate = await user.findOneById(userId); + + if (!authorization.can(userToActivate, "read:activation_token")) { + throw new ForbiddenError({ + message: "Você não pode mais utilizar tokens de ativação.", + action: "Entre em contato com o suporte.", + }); + } + + const activatedUser = await user.setFeatures(userId, [ + "create:session", + "read:session", + ]); return activatedUser; -}; +} async function sendEmailToUser(user, activationToken) { await email.send({ @@ -111,6 +124,7 @@ const activation = { markTokenAsUsed, activateUserByUserId, sendEmailToUser, + EXPIRATION_IN_MILLISECONDS, }; -export default activation; \ No newline at end of file +export default activation; diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js index 89d54f3..3f4ea52 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.js"; const router = createRouter(); -router.patch(patchHandler); +router.use(controller.injectAnonymousOrUser); +router.patch(controller.canRequest("read:activation_token"), patchHandler); export default router.handler(controller.errorHandlers); @@ -12,9 +13,11 @@ async function patchHandler(request, response) { const activationTokenId = request.query.token_id; const validActivationToken = await activation.findOneValidById(activationTokenId); + + await activation.activateUserByUserId(validActivationToken.user_id); + const usedActivationToken = await activation.markTokenAsUsed(activationTokenId); - await activation.activateUserByUserId(validActivationToken.user_id); return response.status(200).json(usedActivationToken); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index 97c27aa..e171fd0 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -1,11 +1,12 @@ import { createRouter } from "next-connect"; -import controller from "infra/controller"; +import controller from "infra/controller.js"; import user from "models/user.js"; -import session from "models/session"; +import session from "models/session.js"; const router = createRouter(); -router.get(getHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:session"), getHandler); export default router.handler(controller.errorHandlers); @@ -14,9 +15,9 @@ async function getHandler(request, response) { const sessionObject = await session.findOneValidByToken(sessionToken); const renewedSessionObject = await session.renew(sessionObject.id); - controller.setSessionCookie(sessionObject.token, response); + controller.setSessionCookie(renewedSessionObject.token, response); - const userFound = await user.findOneById(renewedSessionObject.user_id); + const userFound = await user.findOneById(sessionObject.user_id); response.setHeader( "Cache-Control", diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index 068fb39..b369908 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -5,7 +5,8 @@ import activation from "models/activation.js"; const router = createRouter(); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:user"), postHandler); export default router.handler(controller.errorHandlers); diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index f354d92..a0cd37a 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -13,6 +13,7 @@ beforeAll(async () => { describe("Use case: Registration Flow (all successfull)", () => { let createUserResponseBody; let activationTokenId; + let createSessionsResponseBody; test("Create user account", async () => { const createUserResponse = await fetch("http://localhost:3000/api/v1/users", { @@ -42,7 +43,7 @@ describe("Use case: Registration Flow (all successfull)", () => { }); }); - test("Recive activation email", async () => { + test("Receive activation email", async () => { const lastEmail = await orchestrator.getLastEmail(); expect(lastEmail.sender).toBe(""); @@ -77,7 +78,7 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); const activatedUser = await user.findOneByUsername("RegistrationFlow"); - expect(activatedUser.features).toEqual(["create:session"]); + expect(activatedUser.features).toEqual(["create:session", "read:session"]); }); test("Login", async () => { @@ -97,12 +98,22 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(createSessionResponse.status).toBe(201); - const createSessionResponseBody = await createSessionResponse.json(); + createSessionsResponseBody = await createSessionResponse.json(); - expect(createSessionResponseBody.user_id).toBe(createUserResponseBody.id) + expect(createSessionsResponseBody.user_id).toBe(createUserResponseBody.id) }); 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); + + const userResponseBody = await userResponse.json(); + expect(userResponseBody.id).toBe(createUserResponseBody.id); }); -}) \ No newline at end of file +}) 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..0a082a7 --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,199 @@ +import { version as uuidVersion } from "uuid"; +import activation from "models/activation.js"; +import user from "models/user.js"; +import orchestrator from "tests/orchestrator.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH /api/v1/activations/[token_id]", () => { + describe("Anonymous user", () => { + test("With nonexistent token", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/activations/76a69ff3-6209-40d9-b61d-a6860c18f56d", + { + 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.", + status_code: 404, + }); + }); + + test("With expired token", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - activation.EXPIRATION_IN_MILLISECONDS) + }); + + const createdUser = await orchestrator.createUser(); + const expiredActivationToken = await activation.create(createdUser.id); + + jest.useRealTimers(); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${expiredActivationToken.id}`, + { + 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.", + status_code: 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.", + status_code: 404, + }); + }); + + test("With valid token", async () => { + const createdUser = await orchestrator.createUser(); + const activationToken = await activation.create(createdUser.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: activationToken.id, + used_at: activationToken.used_at, + user_id: activationToken.user_id, + expires_at: activationToken.expires_at.toISOString(), + created_at: activationToken.created_at.toISOString(), + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(uuidVersion(responseBody.user_id)).toBe(4); + + expect(Date.parse(responseBody.expires_at)).not.toBeNaN(); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + + const expiresAt = new Date(responseBody.expires_at); + const createdAt = new Date(responseBody.created_at); + + expiresAt.setMilliseconds(0); + createdAt.setMilliseconds(0); + + expect(expiresAt - createdAt).toBe(activation.EXPIRATION_IN_MILLISECONDS); + + const activatedUser = await user.findOneById(responseBody.user_id); + expect(activatedUser).toEqual([ + "create:session", + "read:session" + ]); + }); + + test("With valid token but already activated 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 utilizar tokens de ativação.", + action: "Entre em contato com o suporte.", + status_code: 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 usuário possui a feature "read:activation_token".', + status_code: 403, + }); + }); + }); +}); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 5c9cd2b..4bcfd33 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -10,12 +10,31 @@ beforeAll(async () => { }); describe("GET /api/v1/user", () => { + describe("Anonymous user", () => { + test("Retrieving 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"', + status_code: 403, + }); + }); + }); + describe("Default user", () => { test("With valid session", async () => { const createdUser = await orchestrator.createUser({ username: "UserWithValidSession", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(createdUser.id); const response = await fetch("http://localhost:3000/api/v1/user", { @@ -38,9 +57,9 @@ describe("GET /api/v1/user", () => { username: "UserWithValidSession", email: createdUser.email, password: createdUser.password, - features: ["read:activation_token"], + features: ["create:session", "read:session"], created_at: createdUser.created_at.toISOString(), - updated_at: createdUser.updated_at.toISOString(), + updated_at: activatedUser.updated_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toBe(4); @@ -73,6 +92,72 @@ describe("GET /api/v1/user", () => { }); }); + test("With halfway-expired session", async () => { + jest.useFakeTimers({ + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS / 2), + }); + + const createdUser = await orchestrator.createUser({ + username: "UserWithHalfwayExpiredSession", + }); + + const activatedUser = await orchestrator.activateUser(createdUser); + + const sessionObject = await orchestrator.createSession(createdUser.id); + + jest.useRealTimers(); + + const response = await fetch("http://localhost:3000/api/v1/user", { + headers: { + cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: createdUser.id, + username: "UserWithHalfwayExpiredSession", + email: createdUser.email, + features: ["create:session", "read:session"], + password: createdUser.password, + created_at: createdUser.created_at.toISOString(), + updated_at: activatedUser.updated_at.toISOString(), + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + // Session renewal assertions + const renewedSessionObject = await session.findOneValidByToken( + sessionObject.token, + ); + + expect( + renewedSessionObject.expires_at > sessionObject.expires_at, + ).toEqual(true); + expect( + renewedSessionObject.updated_at > sessionObject.updated_at, + ).toEqual(true); + + // Set‑Cookie assertions + const parsedSetCookie = setCookieParser(response, { + map: true, + }); + + expect(parsedSetCookie.session_id).toEqual({ + name: "session_id", + value: sessionObject.token, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, + path: "/", + httpOnly: true, + }); + }); + + test("With nonexistent session", async () => { const nonexistentToken = "5a22deb74407dcbb628d7ac12f294c2b8d223c56f384118df9e3b585631c3a4989a6cd511b8581a408f7633cace2c651"; diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 1a65058..802cb77 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 /api/v1/users", () => { }); }); }); + + describe("Default user", () => { + test("With unique and valid data", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1SessionObject = 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=${user1SessionObject.token}`, + }, + body: JSON.stringify({ + username: "usuariologado", + email: "usuariologado@curso.dev", + 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"', + status_code: 403, + }); + }); + }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 994a5b6..c446dea 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -92,7 +92,7 @@ function extractUUID(text) { return math ? math[0] : null; } -async function activatedUser(inactiveUser) { +async function activateUser(inactiveUser) { return await activation.activateUserByUserId(inactiveUser.id); } @@ -105,7 +105,7 @@ const orchestrator = { deleteAllEmails, getLastEmail, extractUUID, - activatedUser, + activateUser, }; export default orchestrator; From 5b4beaecc5c9ed66e5caf286f3e1958706ca6145 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 21:15:10 +0000 Subject: [PATCH 10/20] feat: require update:user to access /api/v1/users/[username] --- models/activation.js | 1 + pages/api/v1/users/[username]/index.js | 3 +- .../_use-cases/registration-flow.test.js | 6 +- .../v1/activations/[token_id]/patch.test.js | 3 +- tests/integration/api/v1/user/get.test.js | 4 +- .../api/v1/users/[username]/patch.test.js | 66 +++++++++++++++++-- 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/models/activation.js b/models/activation.js index 72eeb8a..8e70ece 100644 --- a/models/activation.js +++ b/models/activation.js @@ -100,6 +100,7 @@ async function activateUserByUserId(userId) { const activatedUser = await user.setFeatures(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 cec67eb..9a32573 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.js"; 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/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index a0cd37a..7e7c990 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -78,7 +78,11 @@ describe("Use case: Registration Flow (all successfull)", () => { 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/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js index 0a082a7..870dbb6 100644 --- a/tests/integration/api/v1/activations/[token_id]/patch.test.js +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -136,7 +136,8 @@ describe("PATCH /api/v1/activations/[token_id]", () => { const activatedUser = await user.findOneById(responseBody.user_id); expect(activatedUser).toEqual([ "create:session", - "read:session" + "read:session", + "update:user", ]); }); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 4bcfd33..f4b2832 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -57,7 +57,7 @@ describe("GET /api/v1/user", () => { username: "UserWithValidSession", email: createdUser.email, password: createdUser.password, - features: ["create:session", "read:session"], + features: ["create:session", "read:session", "update:user"], created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_at.toISOString(), }); @@ -121,7 +121,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithHalfwayExpiredSession", email: createdUser.email, - features: ["create:session", "read:session"], + features: ["create:session", "read:session", "update:user"], password: createdUser.password, created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_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 b6f46f3..be28189 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -11,11 +11,48 @@ beforeAll(async () => { describe("PATCH /api/v1/users/[username]", () => { describe("Anonymous user", () => { + test("With unique 'username'", async () => { + const createdUser = await orchestrator.createUser(); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createdUser.username}`, + { + 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", + status_code: 403, + }); + }); + }); + + describe("Default user", () => { test("With nonexistent '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}`, + }, }, ); @@ -36,14 +73,20 @@ describe("PATCH /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", @@ -71,12 +114,18 @@ describe("PATCH /api/v1/users/[username]", () => { email: "email2@email.com", }); + const activatedUser2 = await orchestrator.activateUser(createdUser2); + const sessionObject2 = await orchestrator.createSession( + activatedUser2.id, + ); + const response = await fetch( `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@email.com", @@ -98,6 +147,8 @@ describe("PATCH /api/v1/users/[username]", () => { test("With unique '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/${createdUser.username}`, @@ -105,6 +156,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser2", @@ -121,7 +173,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: "uniqueUser2", email: createdUser.email, password: responseBody.password, - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -135,6 +187,8 @@ describe("PATCH /api/v1/users/[username]", () => { test("With unique 'email'", 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/${createdUser.username}`, @@ -142,6 +196,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ email: "uniqueEmail2@email.com", @@ -158,7 +213,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: "uniqueEmail2@email.com", password: responseBody.password, - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -174,6 +229,8 @@ describe("PATCH /api/v1/users/[username]", () => { 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/${createdUser.username}`, @@ -181,6 +238,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword2", @@ -197,7 +255,7 @@ describe("PATCH /api/v1/users/[username]", () => { username: createdUser.username, email: createdUser.email, password: responseBody.password, - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); From f848439c89d0fa4fc6886dc58237233fe11edf93 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 21:24:18 +0000 Subject: [PATCH 11/20] feat: consider resource in authorization model --- models/authorization.js | 12 +++++++-- pages/api/v1/users/[username]/index.js | 13 +++++++++ .../api/v1/users/[username]/patch.test.js | 27 ++++++++++--------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 469435a..ca4837b 100644 --- a/models/authorization.js +++ b/models/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; } @@ -12,4 +20,4 @@ const authorization = { can, } -export default authorization; \ No newline at end of file +export default authorization; diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 9a32573..18b8b41 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.js"; import user from "models/user.js"; +import authorization from "models/authorization.js"; +import { ForbiddenError } from "infra/errors.js"; const router = createRouter(); @@ -20,6 +22,17 @@ async function patchHandler(request, response) { const username = request.query.username; const userInputValues = request.body; + const userTryingToPatch = request.context.user; + const targetUser = await user.findOneByUsername(username); + + if (!authorization.can(userTryingToPatch, "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 usuário.", + }); + } + const updatedUser = await user.update(username, userInputValues); 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 be28189..a7af872 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -68,40 +68,41 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); - test("With duplicated 'username'", async () => { + test("With `userB` targeting `userA`", async () => { await orchestrator.createUser({ - username: "user1", + username: "userA", }); - const createdUser2 = await orchestrator.createUser({ - username: "user2", + const createdUserB = await orchestrator.createUser({ + username: "userB", }); - const activatedUser2 = await orchestrator.activateUser(createdUser2); + const activatedUserB = await orchestrator.activateUser(createdUserB); const sessionObject2 = await orchestrator.createSession( - activatedUser2.id, + activatedUserB.id, ); - const response = await fetch("http://localhost:3000/api/v1/users/user2", { + const response = await fetch("http://localhost:3000/api/v1/users/userA", { method: "PATCH", headers: { "Content-Type": "application/json", Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ - username: "user1", + username: "userC", }), }); - expect(response.status).toBe(400); + expect(response.status).toBe(403); const responseBody = await response.json(); expect(responseBody).toEqual({ - name: "ValidationError", - message: "O username informado já está sendo utilizado.", - action: "Utilize outro username para realizar esta operação.", - status_code: 400, + action: + "Verifique se você possui a feature necessária para atualizar outro usuário.", + message: "Você não possui permissão para atualizar outro usuário.", + name: "ForbiddenError", + status_code: 403, }); }); From b297915f593eca40f0c44e705441b6aaf93791a3 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 21:28:36 +0000 Subject: [PATCH 12/20] feat: allow update:user:others to update other users --- models/authorization.js | 2 +- models/user.js | 25 +++++++++ .../api/v1/users/[username]/patch.test.js | 52 +++++++++++++++++++ tests/orchestrator.js | 6 +++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/models/authorization.js b/models/authorization.js index ca4837b..2d089e0 100644 --- a/models/authorization.js +++ b/models/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 eb08b7c..a140a78 100644 --- a/models/user.js +++ b/models/user.js @@ -248,6 +248,30 @@ async function setFeatures(userId, features) { } } +async function addFeatures(userId, features) { + const updatedUser = await runUpdateQuery(userId, features); + return updatedUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = array_cat(features, $2), + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + + return results.rows[0]; + } +} + const user = { create, findOneById, @@ -255,6 +279,7 @@ const user = { findOneByEmail, update, setFeatures, + addFeatures, }; export default user; diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index a7af872..6eb55a7 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -282,4 +282,56 @@ describe("PATCH /api/v1/users/[username]", () => { expect(incorrectPasswordMatch).toBe(false); }); }); + + describe("Privileged user", () => { + test("With `update:user: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, + updated_at: responseBody.updated_at, + }); + + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + }); + }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index c446dea..f3af0eb 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -96,6 +96,11 @@ async function activateUser(inactiveUser) { return await activation.activateUserByUserId(inactiveUser.id); } +async function addFeaturesToUser(userObject, features) { + const updatedUser = await user.addFeatures(userObject.id, features); + return updatedUser; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -106,6 +111,7 @@ const orchestrator = { getLastEmail, extractUUID, activateUser, + addFeaturesToUser, }; export default orchestrator; From 9aa219a0c782b03ea7ffef1cc407919837432f58 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 21:58:01 +0000 Subject: [PATCH 13/20] feat: apply authorization.filterOutput() to all endpoints --- models/authorization.js | 79 ++++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 9 +- pages/api/v1/migrations/index.js | 26 +++++- pages/api/v1/sessions/index.js | 17 +++- pages/api/v1/status/index.js | 17 +++- pages/api/v1/user/index.js | 10 +- pages/api/v1/users/[username]/index.js | 19 +++- pages/api/v1/users/index.js | 10 +- .../_use-cases/registration-flow.test.js | 2 - .../integration/api/v1/migrations/get.test.js | 55 ++++++++++- .../api/v1/migrations/post.test.js | 91 +++++++++++++------ tests/integration/api/v1/status/get.test.js | 28 ++++++ tests/integration/api/v1/user/get.test.js | 4 - .../api/v1/users/[username]/get.test.js | 14 +-- .../api/v1/users/[username]/patch.test.js | 52 ++++++++--- tests/integration/api/v1/users/post.test.js | 4 +- 16 files changed, 368 insertions(+), 69 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 2d089e0..24fc908 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -16,8 +16,87 @@ function can(user, feature, resource) { return authorized; } +function filterOutput(user, feature, resource) { + if (feature === "read:user") { + return { + id: resource.id, + username: resource.username, + features: resource.features, + created_at: resource.created_at, + updated_at: resource.updated_at, + }; + } + + if (feature === "read:user:self") { + if (user.id === resource.id) { + return { + id: resource.id, + username: resource.username, + email: resource.email, + features: resource.features, + created_at: resource.created_at, + updated_at: resource.updated_at, + }; + } + } + + if (feature === "read:session") { + if (user.id === resource.user_id) { + return { + id: resource.id, + token: resource.token, + user_id: resource.user_id, + created_at: resource.created_at, + updated_at: resource.updated_at, + expires_at: resource.expires_at, + }; + } + } + + if (feature === "read:activation_token") { + return { + id: resource.id, + user_id: resource.user_id, + created_at: resource.created_at, + updated_at: resource.updated_at, + expires_at: resource.expires_at, + used_at: resource.used_at, + }; + } + + if (feature === "read:migration") { + return resource.map((migration) => { + return { + path: migration.path, + name: migration.name, + timestamp: migration.timestamp, + }; + }); + } + + if (feature === "read:status") { + const output = { + updated_at: resource.updated_at, + dependencies: { + database: { + max_connections: resource.dependencies.database.max_connections, + opened_connections: resource.dependencies.database.opened_connections, + }, + }, + }; + + if (can(user, "read:status:all")) { + output.dependencies.database.version = + resource.dependencies.database.version; + } + + return output; + } +} + 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 3f4ea52..9bc6259 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.js"; import activation from "models/activation.js"; +import authorization from "models/authorization.js"; 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 = await activation.findOneValidById(activationTokenId); @@ -18,6 +20,11 @@ async function patchHandler(request, response) { const usedActivationToken = await activation.markTokenAsUsed(activationTokenId); + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:activation_token", + usedActivationToken, + ); - return response.status(200).json(usedActivationToken); + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/migrations/index.js b/pages/api/v1/migrations/index.js index 0ac4863..1d6772d 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -1,26 +1,42 @@ import { createRouter } from "next-connect"; import controller from "infra/controller.js"; import migrator from "models/migrator.js"; +import authorization from "models/authorization.js"; 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:migration"), 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:migration", + pendingMigrations, + ); + + return response.status(200).json(secureOutputValues); } async function postHandler(request, response) { + const userTryingToPost = request.context.user; const migratedMigrations = await migrator.runPendingMigrations(); + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:migration", + migratedMigrations, + ); + if (migratedMigrations.length > 0) { - return response.status(201).json(migratedMigrations); + return response.status(201).json(secureOutputValues); } - return response.status(200).json(migratedMigrations); + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index ca68d8b..878b649 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -33,15 +33,28 @@ async function postHandler(request, response) { 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(request, response) { + const userTryingToDelete = request.context.user; const sessionToken = request.cookies.session_id; 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( + userTryingToDelete, + "read:session", + expiredSession, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/status/index.js b/pages/api/v1/status/index.js index 00aa880..75e2f26 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -1,13 +1,17 @@ import { createRouter } from "next-connect"; import database from "infra/database.js"; import controller from "infra/controller.js"; +import authorization from "models/authorization.js"; 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;"); @@ -30,10 +34,11 @@ async function getHandler(request, response) { // "SELECT count(*)::int FROM pg_stat_activity WHERE datname = '" + databaseName + "';" // "SELECT count(*)::int FROM pg_stat_activity WHERE datname = 'local_db';" ); + const databaseOpenedConnectionsValue = databaseOpenedConnectionsResult.rows[0].count; - response.status(200).json({ + const statusObject = { updated_at: updatedAt, dependencies: { database: { @@ -42,5 +47,13 @@ async function getHandler(request, response) { opened_connections: databaseOpenedConnectionsValue, }, }, - }); + }; + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:status", + statusObject, + ); + + response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index e171fd0..231ca3b 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.js"; import user from "models/user.js"; import session from "models/session.js"; +import authorization from "models/authorization.js"; 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,11 @@ async function getHandler(request, response) { "no-store, no-cache, 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 18b8b41..6c3a283 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -13,9 +13,17 @@ 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) { @@ -34,5 +42,12 @@ async function patchHandler(request, response) { } const updatedUser = await user.update(username, userInputValues); - 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 b369908..1bab252 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.js"; import user from "models/user.js"; import activation from "models/activation.js"; +import authorization from "models/authorization.js"; 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 userInputValues = request.body; const newUser = await user.create(userInputValues); 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/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 7e7c990..6c4b6f7 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -35,8 +35,6 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, username: "RegistrationFlow", - email: "registration.flow@email.com.br", - password: createUserResponseBody.password, features: ["read:activation_token"], created_at: createUserResponseBody.created_at, updated_at: createUserResponseBody.updated_at, diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index 336c421..08b6dbc 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -3,18 +3,71 @@ import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("GET /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { const response = await fetch("http://localhost:3000/api/v1/migrations"); + + 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"', + status_code: 403, + }); + }); + }); + + describe("Default user", () => { + test("Retrieving pending migrations", 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/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"', + status_code: 403, + }); + }); + }); + + describe("Privileged user", () => { + test("With `read:migration`", async () => { + const createdUser = await orchestrator.createUser(); + const activatedUser = await orchestrator.activateUser(createdUser); + 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); - 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 88b55f9..4d2cf9b 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,39 +3,76 @@ import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("POST /api/v1/migrations", () => { describe("Anonymous user", () => { - describe("Running pending migrations", () => { - test("For the first 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/migrations", { + method: "POST", }); - test("For the second time", async () => { - const response2 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response2.status).toBe(200); - - const response2Body = await response2.json(); - - expect(Array.isArray(response2Body)).toBe(true); - expect(response2Body.length).toBe(0); + + 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 "create:migration"', + status_code: 403, + }); + }); + }); + + describe("Default user", () => { + test("Retrieving pending migrations", 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/migrations", { + method: "POST", + 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 "create:migration"', + status_code: 403, + }); + }); + }); + + describe("Privileged user", () => { + 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/status/get.test.js b/tests/integration/api/v1/status/get.test.js index 9dc4fab..da32da4 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -15,6 +15,34 @@ describe("GET /api/v1/status", () => { const parsedUpdatedAt = new Date(responseBody.updated_at).toISOString(); expect(responseBody.updated_at).toEqual(parsedUpdatedAt); + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.opened_connections).toEqual(1); + expect(responseBody.dependencies.database).not.toHaveProperty("version"); + }); + }); + + describe("Privileged user", () => { + test("With `read:status:all`", async () => { + const privilegedUser = await orchestrator.createUser(); + const activatedPrivilegedUser = + await orchestrator.activateUser(privilegedUser); + await orchestrator.addFeaturesToUser(privilegedUser, ["read:status:all"]); + const privilegedUserSession = await orchestrator.createSession( + activatedPrivilegedUser.id, + ); + + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${privilegedUserSession.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + const parsedUpdatedAt = new Date(responseBody.updated_at).toISOString(); + expect(responseBody.updated_at).toEqual(parsedUpdatedAt); + expect(responseBody.dependencies.database.version).toEqual("16.0"); expect(responseBody.dependencies.database.max_connections).toEqual(100); expect(responseBody.dependencies.database.opened_connections).toEqual(1); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index f4b2832..64db553 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -55,8 +55,6 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithValidSession", - email: createdUser.email, - password: createdUser.password, features: ["create:session", "read:session", "update:user"], created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_at.toISOString(), @@ -120,9 +118,7 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithHalfwayExpiredSession", - email: createdUser.email, features: ["create:session", "read:session", "update:user"], - password: createdUser.password, created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_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 5a72fd3..1ea3ae7 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -10,7 +10,7 @@ beforeAll(async () => { describe("GET /api/v1/users/[username]", () => { describe("Anonymous user", () => { test("With exact case match", async () => { - const createdUser = await orchestrator.createUser({ + await orchestrator.createUser({ username: "MesmoCase", }); @@ -25,9 +25,9 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "MesmoCase", - email: createdUser.email, - password: responseBody.password, + features: ["read:activation_token"], + created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -38,7 +38,7 @@ describe("GET /api/v1/users/[username]", () => { }); test("With case mismatch", async () => { - const createdUser = await orchestrator.createUser({ + await orchestrator.createUser({ username: "CaseDiferente", }); @@ -53,9 +53,9 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "CaseDiferente", - email: createdUser.email, - password: responseBody.password, + features: ["read:activation_token"], + created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -77,7 +77,7 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username foi digitado corretamente", + action: "Verifique se o username está digitado corretamente.", status_code: 404, }); }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 6eb55a7..32ba4cd 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -63,11 +63,48 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username foi digitado corretamente", + action: "Verifique se o username está digitado corretamente.", status_code: 404, }); }); + test("With duplicated 'username'", async () => { + await orchestrator.createUser({ + username: "user1", + }); + + 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", + }), + }); + + expect(response.status).toBe(400); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ValidationError", + message: "O username informado já está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + status_code: 400, + }); + }); + test("With `userB` targeting `userA`", async () => { await orchestrator.createUser({ username: "userA", @@ -129,7 +166,7 @@ describe("PATCH /api/v1/users/[username]", () => { Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ - email: "email1@email.com", + email: "email1@curso.dev", }), }, ); @@ -172,8 +209,6 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "uniqueUser2", - email: createdUser.email, - password: responseBody.password, features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -193,6 +228,7 @@ describe("PATCH /api/v1/users/[username]", () => { const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, + { method: "PATCH", headers: { @@ -200,7 +236,7 @@ describe("PATCH /api/v1/users/[username]", () => { Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ - email: "uniqueEmail2@email.com", + email: "uniqueEmail2@curso.dev", }), }, ); @@ -212,8 +248,6 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: "uniqueEmail2@email.com", - password: responseBody.password, features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -254,8 +288,6 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: createdUser.email, - password: responseBody.password, features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -320,9 +352,7 @@ describe("PATCH /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, updated_at: responseBody.updated_at, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 802cb77..8e255e5 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,8 +31,6 @@ describe("POST /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "alannub", - email: "alannub@email.com", - password: responseBody.password, features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -150,7 +148,7 @@ describe("POST /api/v1/users", () => { }, body: JSON.stringify({ username: "usuariologado", - email: "usuariologado@curso.dev", + email: "usuariologado@email.com", password: "senha123", }), }); From 8d08a15e356217c165010432eef500abe1d71f62 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 22:02:39 +0000 Subject: [PATCH 14/20] feat: validate user, feature and resource in authorization model --- models/authorization.js | 59 +++++++++++++ tests/unit/models/authorization.test.js | 108 ++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 tests/unit/models/authorization.test.js diff --git a/models/authorization.js b/models/authorization.js index 24fc908..541ad78 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -1,4 +1,33 @@ +import { InternalServerError } from "infra/errors.js"; + +const availableFeatures = [ + // USER + "create:user", + "read:user", + "read:user:self", + "update:user", + "update:user:others", + + // SESSION + "create:session", + "read:session", + + // ACTIVATION_TOKEN + "read:activation_token", + + // MIGRATION + "create:migration", + "read:migration", + + // STATUS + "read:status", + "read:status:all", +]; + 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, resource) { + validateUser(user); + validateFeature(feature); + validateResource(resource); + if (feature === "read:user") { return { id: resource.id, @@ -94,6 +127,32 @@ function filterOutput(user, feature, resource) { } } +function validateUser(user) { + if (!user || !user.features) { + throw new InternalServerError({ + cause: "É necessário fornecer `user` no model `authorization`.", + }); + } +} + +function validateFeature(feature) { + if (!feature || !availableFeatures.includes(feature)) { + throw new InternalServerError({ + cause: + "É necessário fornecer uma `feature` conhecida no model `authorization`.", + }); + } +} + +function validateResource(resource) { + if (!resource) { + throw new InternalServerError({ + cause: + "É necessário fornecer um `resource` em `authorization.filterOutput()`.", + }); + } +} + const authorization = { can, filterOutput, diff --git a/tests/unit/models/authorization.test.js b/tests/unit/models/authorization.test.js new file mode 100644 index 0000000..e69a5a1 --- /dev/null +++ b/tests/unit/models/authorization.test.js @@ -0,0 +1,108 @@ +import authorization from "models/authorization.js"; +import { InternalServerError } from "infra/errors.js"; + +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 valid `user` and known `feature`", () => { + const createdUser = { + features: ["create:user"], + }; + + expect(authorization.can(createdUser, "create:user")).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 valid `user`, known `feature` but no `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + expect(() => { + authorization.filterOutput(createdUser, "read:user"); + }).toThrow(InternalServerError); + }); + + test("with valid `user`, known `feature` and `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + const resource = { + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + updated_at: "2026-0101T00:00:00.000Z", + email: "resource@resource.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", + updated_at: "2026-0101T00:00:00.000Z", + }); + }); + }); +}); From 68550c9c22d03bb1761b7fa4524bc2225c394b83 Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 22:05:03 +0000 Subject: [PATCH 15/20] chore: update Node.js version to 24 --- .nvmrc | 2 +- package-lock.json | 101 ++-------------------------------------------- package.json | 3 ++ 3 files changed, 7 insertions(+), 99 deletions(-) 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 59c3f0a..78c859f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,9 @@ "jest": "29.7.0", "prettier": "3.3.3", "set-cookie-parser": "2.7.1" + }, + "engines": { + "node": "24" } }, "node_modules/@ampproject/remapping": { @@ -2226,18 +2229,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/@types/pg": { - "version": "8.11.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", - "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -8825,13 +8816,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "optional": true, - "peer": true - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9180,16 +9164,6 @@ "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/pg-pool": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", @@ -9203,25 +9177,6 @@ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" }, - "node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "optional": true, - "peer": true, - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -9415,56 +9370,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", - "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "optional": true, - "peer": true, - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "optional": true, - "peer": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 590fdb7..33bbfb3 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "engines": { + "node": "24" } } From 1992f7aa03d5d8e6374a10be9e942de36e26caae Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 22:08:48 +0000 Subject: [PATCH 16/20] ci: align Node.js version with package.json --- .github/workflows/linting.yaml | 6 +++--- .github/workflows/tests.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 819094e..5096229 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "lts/hydrogen" + node-version-file: "package.json" - run: npm ci @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "lts/hydrogen" + node-version-file: "package.json" - run: npm ci @@ -39,7 +39,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "lts/hydrogen" + node-version-file: "package.json" - run: npm ci diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c60868a..5225fb7 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "lts/hydrogen" + node-version-file: "package.json" - run: npm ci From 95e4b40597ac15a29764344edef0ae786f4147ca Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 23:27:25 +0000 Subject: [PATCH 17/20] chore: add migrations:up:dry npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 33bbfb3..c4d1618 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,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": "next lint --dir .", From 249f55d7efb3d09e23e11c945de757f84b31531b Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Fri, 13 Feb 2026 23:33:06 +0000 Subject: [PATCH 18/20] refactor: improve error loggin in email.send() --- infra/email.js | 12 +++++++++++- infra/errors.js | 10 ++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/infra/email.js b/infra/email.js index 13d7883..a1529a4 100644 --- a/infra/email.js +++ b/infra/email.js @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { ServiceError } from "./errors.js"; 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 possível enviar o email.", + action: "Verifique se o serviço de email está disponível.", + cause: error, + context: mailOptions, + }); + } } const email = { diff --git a/infra/errors.js b/infra/errors.js index 3943d44..11ba711 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -4,7 +4,7 @@ export class InternalServerError extends Error { cause, }); this.name = "InternalServerError"; - this.action = "Entre em contato com o suporte"; + this.action = "Entre em contato com o suporte."; this.statusCode = statusCode || 500; } @@ -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 = "ServiceError"; - this.action = "Verifique se o serviço está disponível."; + this.action = action ||"Verifique se o serviço está disponível."; this.statusCode = 503; + this.context = context; } toJSON() { @@ -34,6 +35,7 @@ export class ServiceError extends Error { message: this.message, action: this.action, status_code: this.statusCode, + context: this.context, }; } } @@ -136,4 +138,4 @@ export class ForbiddenError extends Error { status_code: this.statusCode, }; } -} \ No newline at end of file +} From b878b25f2709ad2db2390add451d4985d305838a Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Sat, 14 Feb 2026 00:17:29 +0000 Subject: [PATCH 19/20] fix: lint correction and some typing adjustments to fix errors in the tests --- infra/controller.js | 17 ++++++---- infra/database.js | 2 +- infra/errors.js | 5 +-- ...569579335_create-user-activation-tokens.js | 5 ++- infra/webserver.js | 10 +++--- models/activation.js | 23 ++++++------- models/authorization.js | 2 +- models/session.js | 4 +-- models/user.js | 4 +-- pages/api/v1/activations/[token_id]/index.js | 10 +++--- pages/api/v1/sessions/index.js | 6 ++-- pages/api/v1/status/index.js | 4 +-- pages/api/v1/users/[username]/index.js | 2 +- pages/api/v1/users/index.js | 2 +- .../_use-cases/registration-flow.test.js | 20 ++++++----- .../v1/activations/[token_id]/patch.test.js | 33 ++++++++++--------- .../integration/api/v1/migrations/get.test.js | 1 - .../api/v1/sessions/delete.test.js | 10 +++--- .../integration/api/v1/sessions/post.test.js | 2 +- tests/integration/api/v1/user/get.test.js | 9 ++--- .../api/v1/users/[username]/get.test.js | 2 +- .../api/v1/users/[username]/patch.test.js | 4 +-- tests/integration/infra/email.test.js | 2 +- tests/orchestrator.js | 4 +-- 24 files changed, 98 insertions(+), 85 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 2b3dc8f..111ef17 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -19,7 +19,11 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { - if (error instanceof ValidationError || error instanceof NotFoundError || error instanceof ForbiddenError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ForbiddenError + ) { return response.status(error.statusCode).json(error); } @@ -71,7 +75,7 @@ async function injectAnonymousOrUser(request, response, next) { async function injectAuthenticatedUser(request) { const sessionToken = request.cookies.session_id; - const sessionObject = session.findOneValidByToken(sessionToken); + const sessionObject = await session.findOneValidByToken(sessionToken); const userObject = await user.findOneById(sessionObject.user_id); request.context = { @@ -92,19 +96,18 @@ function injectAnonymousUser(request) { } function canRequest(feature) { - return (request, response, next) => { - const useTryingToRequest = request.context.user; + const userTryingToRequest = request.context.user; - if (authorization.can(useTryingToRequest, feature)) { + if (authorization.can(userTryingToRequest, feature)) { return next(); } throw new ForbiddenError({ - message: "Você não possui permissão para executar essa ação.", + 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 = { diff --git a/infra/database.js b/infra/database.js index 06eee0e..58961c9 100644 --- a/infra/database.js +++ b/infra/database.js @@ -9,7 +9,7 @@ async function query(queryObject) { return result; } catch (error) { const serviceErrorObject = new ServiceError({ - message: "Erro na conexão com Banco ou na Query", + message: "Erro na conexão com Banco ou na Query.", cause: error, }); throw serviceErrorObject; diff --git a/infra/errors.js b/infra/errors.js index 11ba711..2915362 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -24,7 +24,7 @@ export class ServiceError extends Error { cause, }); this.name = "ServiceError"; - this.action = action ||"Verifique se o serviço está disponível."; + this.action = action || "Verifique se o serviço está disponível."; this.statusCode = 503; this.context = context; } @@ -126,7 +126,8 @@ export class ForbiddenError extends Error { cause, }); this.name = "ForbiddenError"; - this.action = action || "Verifique as features necessárias antes de continuar."; + this.action = + action || "Verifique as features necessárias antes de continuar."; this.statusCode = 403; } diff --git a/infra/migrations/1760569579335_create-user-activation-tokens.js b/infra/migrations/1760569579335_create-user-activation-tokens.js index bfdab12..7b3bb4e 100644 --- a/infra/migrations/1760569579335_create-user-activation-tokens.js +++ b/infra/migrations/1760569579335_create-user-activation-tokens.js @@ -16,7 +16,7 @@ exports.up = (pgm) => { notNull: true, }, - expires_at: { + expires_at: { type: "timestamptz", notNull: true, }, @@ -32,8 +32,7 @@ exports.up = (pgm) => { notNull: true, default: pgm.func("timezone('utc', now())"), }, - }) + }); }; exports.down = false; - diff --git a/infra/webserver.js b/infra/webserver.js index a251cc0..899c794 100644 --- a/infra/webserver.js +++ b/infra/webserver.js @@ -1,17 +1,17 @@ function getOrigin() { if (["test", "development"].includes(process.env.NODE_ENV)) { - return "http://localhost:3000" + return "http://localhost:3000"; } if (process.env.VERCEL_ENV === "preview") { - return `https://${process.env.VERCEL_URL}` + return `https://${process.env.VERCEL_URL}`; } - return "https://alannub.com.br" + return "https://alannub.com.br"; } const webserver = { origin: getOrigin(), -} +}; -export default webserver; \ No newline at end of file +export default webserver; diff --git a/models/activation.js b/models/activation.js index 8e70ece..670f12f 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,7 +1,7 @@ -import email from "infra/email.js" +import email from "infra/email.js"; import database from "infra/database.js"; import webserver from "infra/webserver.js"; -import user from "models/user.js" +import user from "models/user.js"; import { ForbiddenError, NotFoundError } from "infra/errors.js"; import authorization from "models/authorization.js"; @@ -26,14 +26,15 @@ async function findOneValidById(tokenId) { LIMIT 1 ;`, - values: [tokenId] + 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." - }) + 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]; @@ -56,7 +57,7 @@ async function create(userId) { RETURNING * ;`, - values: [userId, expiresAt] + values: [userId, expiresAt], }); return results.rows[0]; @@ -66,7 +67,7 @@ async function create(userId) { async function markTokenAsUsed(activationTokenId) { const usedActivationToken = await runUpdateQuery(activationTokenId); return usedActivationToken; - + async function runUpdateQuery(activationTokenId) { const results = await database.query({ text: ` @@ -84,8 +85,8 @@ async function markTokenAsUsed(activationTokenId) { }); return results.rows[0]; - }; -}; + } +} async function activateUserByUserId(userId) { const userToActivate = await user.findOneById(userId); @@ -117,7 +118,7 @@ ${webserver.origin}/cadastro/ativar/${activationToken.id} Atenciosamente, Equipe FinTab`, }); -}; +} const activation = { findOneValidById, diff --git a/models/authorization.js b/models/authorization.js index 541ad78..7546585 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -156,6 +156,6 @@ function validateResource(resource) { const authorization = { can, filterOutput, -} +}; export default authorization; diff --git a/models/session.js b/models/session.js index 00cd463..31865ad 100644 --- a/models/session.js +++ b/models/session.js @@ -21,14 +21,14 @@ async function findOneValidByToken(sessionToken) { AND expires_at > NOW() LIMIT 1 - `, + ;`, values: [sessionToken], }); if (results.rowCount === 0) { throw new UnauthorizedError({ message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", }); } diff --git a/models/user.js b/models/user.js index a140a78..b628e1b 100644 --- a/models/user.js +++ b/models/user.js @@ -56,7 +56,7 @@ async function findOneByUsername(username) { if (results.rowCount === 0) { throw new NotFoundError({ message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username foi digitado corretamente", + action: "Verifique se o username foi digitado corretamente.", }); } @@ -96,8 +96,8 @@ async function findOneByEmail(email) { } async function create(userInputValues) { - await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); + await validateUniqueEmail(userInputValues.email); await hashPasswordInObject(userInputValues); injectDefaultFeaturesInObject(userInputValues); diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js index 9bc6259..d767984 100644 --- a/pages/api/v1/activations/[token_id]/index.js +++ b/pages/api/v1/activations/[token_id]/index.js @@ -14,11 +14,13 @@ async function patchHandler(request, response) { const userTryingToPatch = request.context.user; const activationTokenId = request.query.token_id; - const validActivationToken = await activation.findOneValidById(activationTokenId); - + const validActivationToken = + await activation.findOneValidById(activationTokenId); + await activation.activateUserByUserId(validActivationToken.user_id); - - const usedActivationToken = await activation.markTokenAsUsed(activationTokenId); + + const usedActivationToken = + await activation.markTokenAsUsed(activationTokenId); const secureOutputValues = authorization.filterOutput( userTryingToPatch, diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 878b649..e1c8472 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -24,9 +24,9 @@ async function postHandler(request, response) { if (!authorization.can(authenticatedUser, "create:session")) { throw new ForbiddenError({ - message: "Você não possui permissão para fazer login", - action: "Contate o suporte caso você acredite que isto seja um erro." - }) + message: "Você não possui permissão para fazer login.", + action: "Contate o suporte caso você acredite que isto seja um erro.", + }); } const newSession = await session.create(authenticatedUser.id); diff --git a/pages/api/v1/status/index.js b/pages/api/v1/status/index.js index 75e2f26..47ea2c4 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -38,7 +38,7 @@ async function getHandler(request, response) { const databaseOpenedConnectionsValue = databaseOpenedConnectionsResult.rows[0].count; - const statusObject = { + const statusObject = { updated_at: updatedAt, dependencies: { database: { @@ -48,7 +48,7 @@ async function getHandler(request, response) { }, }, }; - + const secureOutputValues = authorization.filterOutput( userTryingToGet, "read:status", diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 6c3a283..17f4b16 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -42,7 +42,7 @@ async function patchHandler(request, response) { } const updatedUser = await user.update(username, userInputValues); - + const secureOutputValues = authorization.filterOutput( userTryingToPatch, "read:user", diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index 1bab252..1a10f76 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -16,7 +16,7 @@ async function postHandler(request, response) { const userInputValues = request.body; const newUser = await user.create(userInputValues); - const activationToken = await activation.create(newUser.id) + const activationToken = await activation.create(newUser.id); await activation.sendEmailToUser(newUser, activationToken); const secureOutputValues = authorization.filterOutput( diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 6c4b6f7..5909d9f 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,7 +1,7 @@ import webserver from "infra/webserver.js"; import activation from "models/activation.js"; import orchestrator from "tests/orchestrator.js"; -import user from "models/user.js" +import user from "models/user.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -10,13 +10,15 @@ beforeAll(async () => { await orchestrator.deleteAllEmails(); }); -describe("Use case: Registration Flow (all successfull)", () => { +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", { + const createUserResponse = await fetch( + "http://localhost:3000/api/v1/users", + { method: "POST", headers: { "Content-Type": "application/json", @@ -24,9 +26,10 @@ describe("Use case: Registration Flow (all successfull)", () => { body: JSON.stringify({ username: "RegistrationFlow", email: "registration.flow@email.com.br", - password: "RegistrationFlowPassword" + password: "RegistrationFlowPassword", }), - }); + }, + ); expect(createUserResponse.status).toBe(201); @@ -55,7 +58,8 @@ describe("Use case: Registration Flow (all successfull)", () => { `${webserver.origin}/cadastro/ativar/${activationTokenId}`, ); - const activationTokenObject = await activation.findOneValidById(activationTokenId); + const activationTokenObject = + await activation.findOneValidById(activationTokenId); expect(activationTokenObject.user_id).toBe(createUserResponseBody.id); expect(activationTokenObject.used_at).toBe(null); @@ -102,7 +106,7 @@ describe("Use case: Registration Flow (all successfull)", () => { createSessionsResponseBody = await createSessionResponse.json(); - expect(createSessionsResponseBody.user_id).toBe(createUserResponseBody.id) + expect(createSessionsResponseBody.user_id).toBe(createUserResponseBody.id); }); test("Get user information", async () => { @@ -118,4 +122,4 @@ describe("Use case: Registration Flow (all successfull)", () => { expect(userResponseBody.id).toBe(createUserResponseBody.id); }); -}) +}); diff --git a/tests/integration/api/v1/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js index 870dbb6..a175dfe 100644 --- a/tests/integration/api/v1/activations/[token_id]/patch.test.js +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -25,7 +25,8 @@ describe("PATCH /api/v1/activations/[token_id]", () => { expect(responseBody).toEqual({ name: "NotFoundError", - message: "O token de ativação utilizado não foi encontrado no sistema ou expirou.", + message: + "O token de ativação utilizado não foi encontrado no sistema ou expirou.", action: "Faça um novo cadastro.", status_code: 404, }); @@ -33,7 +34,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { test("With expired token", async () => { jest.useFakeTimers({ - now: new Date(Date.now() - activation.EXPIRATION_IN_MILLISECONDS) + now: new Date(Date.now() - activation.EXPIRATION_IN_MILLISECONDS), }); const createdUser = await orchestrator.createUser(); @@ -51,14 +52,14 @@ describe("PATCH /api/v1/activations/[token_id]", () => { 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.", + message: + "O token de ativação utilizado não foi encontrado no sistema ou expirou.", action: "Faça um novo cadastro.", status_code: 404, }); - }); test("With already used token", async () => { @@ -84,10 +85,11 @@ describe("PATCH /api/v1/activations/[token_id]", () => { 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.", + message: + "O token de ativação utilizado não foi encontrado no sistema ou expirou.", action: "Faça um novo cadastro.", status_code: 404, }); @@ -110,7 +112,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { expect(responseBody).toEqual({ id: activationToken.id, - used_at: activationToken.used_at, + used_at: responseBody.used_at, user_id: activationToken.user_id, expires_at: activationToken.expires_at.toISOString(), created_at: activationToken.created_at.toISOString(), @@ -134,7 +136,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { expect(expiresAt - createdAt).toBe(activation.EXPIRATION_IN_MILLISECONDS); const activatedUser = await user.findOneById(responseBody.user_id); - expect(activatedUser).toEqual([ + expect(activatedUser.features).toEqual([ "create:session", "read:session", "update:user", @@ -142,9 +144,9 @@ describe("PATCH /api/v1/activations/[token_id]", () => { }); test("With valid token but already activated user", async () => { - const createUser = await orchestrator.createUser(); - await orchestrator.activateUser(createUser); - const activationToken = await activation.create(createUser.id); + const createdUser = await orchestrator.createUser(); + await orchestrator.activateUser(createdUser); + const activationToken = await activation.create(createdUser.id); const response = await fetch( `http://localhost:3000/api/v1/activations/${activationToken.id}`, @@ -170,7 +172,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { 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 user1SessionObject = await orchestrator.createSession(user1.id); const user2 = await orchestrator.createUser(); const user2ActivationToken = await activation.create(user2.id); @@ -180,7 +182,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { { method: "PATCH", headers: { - Cookie: `session_id=${user1SessionObject.token}` + Cookie: `session_id=${user1SessionObject.token}`, }, }, ); @@ -192,7 +194,8 @@ describe("PATCH /api/v1/activations/[token_id]", () => { expect(responseBody).toEqual({ name: "ForbiddenError", message: "Você não possui permissão para executar esta ação.", - action: 'Verifique se o usuário possui a feature "read:activation_token".', + action: + 'Verifique se o seu usuário possui a feature "read:activation_token"', status_code: 403, }); }); diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index 08b6dbc..802d424 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -67,7 +67,6 @@ describe("GET /api/v1/migrations", () => { 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 1a38c0d..e378fd9 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -29,7 +29,7 @@ describe("DELETE /api/v1/sessions", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); }); @@ -59,7 +59,7 @@ describe("DELETE /api/v1/sessions", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); }); @@ -96,10 +96,10 @@ describe("DELETE /api/v1/sessions", () => { expect( responseBody.expires_at < sessionObject.expires_at.toISOString(), - ).toEqual(true); + ).toBe(true); expect( responseBody.updated_at > sessionObject.updated_at.toISOString(), - ).toEqual(true); + ).toBe(true); // Set-Cookie assertions const parsedSetCookie = setCookieParser(response, { @@ -131,7 +131,7 @@ describe("DELETE /api/v1/sessions", () => { expect(doubleCheckResponseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); }); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 4a447fb..f5a4244 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -1,6 +1,6 @@ import { version as uuidVersion } from "uuid"; import setCookieParser from "set-cookie-parser"; -import orchestrator from "tests/orchestrator"; +import orchestrator from "tests/orchestrator.js"; import session from "models/session.js"; beforeAll(async () => { diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 64db553..6fb24b3 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -26,7 +26,7 @@ describe("GET /api/v1/user", () => { }); }); }); - + describe("Default user", () => { test("With valid session", async () => { const createdUser = await orchestrator.createUser({ @@ -55,6 +55,7 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithValidSession", + email: createdUser.email, features: ["create:session", "read:session", "update:user"], created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_at.toISOString(), @@ -118,6 +119,7 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithHalfwayExpiredSession", + email: createdUser.email, features: ["create:session", "read:session", "update:user"], created_at: createdUser.created_at.toISOString(), updated_at: activatedUser.updated_at.toISOString(), @@ -153,7 +155,6 @@ describe("GET /api/v1/user", () => { }); }); - test("With nonexistent session", async () => { const nonexistentToken = "5a22deb74407dcbb628d7ac12f294c2b8d223c56f384118df9e3b585631c3a4989a6cd511b8581a408f7633cace2c651"; @@ -171,7 +172,7 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); @@ -215,7 +216,7 @@ describe("GET /api/v1/user", () => { expect(responseBody).toEqual({ name: "UnauthorizedError", message: "Usuário não possui sessão ativa.", - action: "Verifique se esse usuário está logado e tente novamente.", + action: "Verifique se este usuário está logado e tente novamente.", status_code: 401, }); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 1ea3ae7..a03acc2 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -77,7 +77,7 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username está digitado corretamente.", + action: "Verifique se o username foi digitado corretamente.", status_code: 404, }); }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 32ba4cd..e7cfd3d 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -63,7 +63,7 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username está digitado corretamente.", + action: "Verifique se o username foi digitado corretamente.", status_code: 404, }); }); @@ -166,7 +166,7 @@ describe("PATCH /api/v1/users/[username]", () => { Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ - email: "email1@curso.dev", + email: "email1@email.com", }), }, ); diff --git a/tests/integration/infra/email.test.js b/tests/integration/infra/email.test.js index 2011020..c877dc5 100644 --- a/tests/integration/infra/email.test.js +++ b/tests/integration/infra/email.test.js @@ -12,7 +12,7 @@ describe("infra/email.js", () => { await email.send({ from: "AlanNub ", to: "contato@alannub.com.br", - subject: "Teste de assunto", + subject: "Teste de assunto.", text: "Teste de corpo", // html: ... }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index f3af0eb..a9fac98 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -88,8 +88,8 @@ async function getLastEmail() { } function extractUUID(text) { - const math = text.match(/[0-9a-fA-F-]{36}/); - return math ? math[0] : null; + const match = text.match(/[0-9a-fA-F-]{36}/); + return match ? match[0] : null; } async function activateUser(inactiveUser) { From 9b6a70f2be943136abb4d0a38197d950c46f8adc Mon Sep 17 00:00:00 2001 From: Alan Miranda Date: Wed, 18 Mar 2026 23:56:09 +0000 Subject: [PATCH 20/20] fix: change email value --- infra/webserver.js | 2 +- models/activation.js | 2 +- tests/integration/_use-cases/registration-flow.test.js | 2 +- tests/integration/infra/email.test.js | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/infra/webserver.js b/infra/webserver.js index 899c794..cd41b61 100644 --- a/infra/webserver.js +++ b/infra/webserver.js @@ -7,7 +7,7 @@ function getOrigin() { return `https://${process.env.VERCEL_URL}`; } - return "https://alannub.com.br"; + return "https://clone-tabnews.alannub.site"; } const webserver = { diff --git a/models/activation.js b/models/activation.js index 670f12f..3463e90 100644 --- a/models/activation.js +++ b/models/activation.js @@ -108,7 +108,7 @@ async function activateUserByUserId(userId) { async function sendEmailToUser(user, activationToken) { await email.send({ - from: "AlanNub ", + from: "AlanNub ", to: user.email, subject: "Ative seu cadastro no Fintab!", text: `${user.username}, clique no link abaixo para ativar seu cadastro no Fintab: diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 5909d9f..4a49393 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -47,7 +47,7 @@ describe("Use case: Registration Flow (all successful)", () => { test("Receive activation email", async () => { const lastEmail = await orchestrator.getLastEmail(); - expect(lastEmail.sender).toBe(""); + expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Ative seu cadastro no Fintab!"); expect(lastEmail.text).toContain("RegistrationFlow"); diff --git a/tests/integration/infra/email.test.js b/tests/integration/infra/email.test.js index c877dc5..01a5119 100644 --- a/tests/integration/infra/email.test.js +++ b/tests/integration/infra/email.test.js @@ -10,7 +10,7 @@ describe("infra/email.js", () => { await orchestrator.deleteAllEmails(); await email.send({ - from: "AlanNub ", + from: "AlanNub ", to: "contato@alannub.com.br", subject: "Teste de assunto.", text: "Teste de corpo", @@ -18,14 +18,14 @@ describe("infra/email.js", () => { }); await email.send({ - from: "AlanNub ", + from: "AlanNub ", to: "contato@alannub.com.br", subject: "Último email enviado", text: "Corpo do último email", }); const lastEmail = await orchestrator.getLastEmail(); - expect(lastEmail.sender).toBe(""); + expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Último email enviado"); expect(lastEmail.text).toBe("Corpo do último email\r\n");