From ee54c94091aaa98aca7c220ed1acf1e1cad5742e Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 23 Jul 2026 12:49:22 +0000 Subject: [PATCH 01/21] feat: add `features` column to `users` table --- .../migrations/1784806098046_add-features-to-users.js | 11 +++++++++++ tests/integration/api/v1/user/get.test.js | 2 ++ tests/integration/api/v1/users/[username]/get.test.js | 2 ++ .../integration/api/v1/users/[username]/patch.test.js | 3 +++ tests/integration/api/v1/users/post.test.js | 1 + 5 files changed, 19 insertions(+) create mode 100644 infra/migrations/1784806098046_add-features-to-users.js diff --git a/infra/migrations/1784806098046_add-features-to-users.js b/infra/migrations/1784806098046_add-features-to-users.js new file mode 100644 index 0000000..f2d73c4 --- /dev/null +++ b/infra/migrations/1784806098046_add-features-to-users.js @@ -0,0 +1,11 @@ +exports.up = (pgm) => { + pgm.addColumn("users", { + features: { + type: "varchar[]", + notNull: true, + default: "{}", + }, + }); +}; + +exports.down = false; diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index e8c5c8d..a1f1508 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -37,6 +37,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithValidSession", email: createdUser.email, + features: [], password: createdUser.password, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), @@ -108,6 +109,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWith1MinuteValidSession", email: createdUser.email, + features: [], password: createdUser.password, 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 d794d7f..db6dc6a 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -28,6 +28,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "SameCase", email: "same.case@email.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -57,6 +58,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "DifferentCase", email: "different.case@email.com", + features: [], password: responseBody.password, 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 dd8f269..34278f8 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -122,6 +122,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser2", email: createdUser.email, + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -160,6 +161,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail2@email.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -198,6 +200,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: createdUser.email, + 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 eb78624..35afd52 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -32,6 +32,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "username", email: "user@email.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, From b650d1322d09b9cd07259c177c96ff1b84d77a31 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 23 Jul 2026 13:31:45 +0000 Subject: [PATCH 02/21] feat: add default feature `read:activation_token` when creating `user` --- models/user.js | 10 +++- .../_use-cases/registration-flow.test.js | 49 +++++++++++++++++++ tests/integration/api/v1/user/get.test.js | 4 +- .../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, 65 insertions(+), 10 deletions(-) create mode 100644 tests/integration/_use-cases/registration-flow.test.js diff --git a/models/user.js b/models/user.js index 3a43dd2..ba02d2a 100644 --- a/models/user.js +++ b/models/user.js @@ -95,6 +95,7 @@ async function create(userInputValues) { await validateUniqueUsername(userInputValues.username); await validateUniqueEmail(userInputValues.email); await hashPasswordInObject(userInputValues); + injectDefaultFeaturesInObject(userInputValues); const newUser = await runInsertQuery(userInputValues); return newUser; @@ -103,9 +104,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 * ;`, @@ -113,11 +114,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..99899f5 --- /dev/null +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -0,0 +1,49 @@ +import orchestrator from "tests/orchestrator.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); + await orchestrator.deleteAllEmails(); +}); + +describe("Use case: Registration Flow (all successful)", () => { + test("Create user acount", 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", + 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", + features: ["read:activation_token"], + password: createUserResponseBody.password, + created_at: createUserResponseBody.created_at, + updated_at: createUserResponseBody.updated_at, + }); + }); + + test("Receive activation email", async () => {}); + + test("Activate account", async () => {}); + + test("Login", async () => {}); + + test("Get user information", async () => {}); +}); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index a1f1508..35f04d2 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -37,7 +37,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithValidSession", email: createdUser.email, - features: [], + features: ["read:activation_token"], password: createdUser.password, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), @@ -109,7 +109,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWith1MinuteValidSession", email: createdUser.email, - features: [], + features: ["read:activation_token"], password: createdUser.password, 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 db6dc6a..3881293 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -28,7 +28,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "SameCase", email: "same.case@email.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -58,7 +58,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "DifferentCase", email: "different.case@email.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, 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 34278f8..25a79d9 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -122,7 +122,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser2", email: createdUser.email, - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -161,7 +161,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail2@email.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -200,7 +200,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: createdUser.email, - features: [], + features: ["read:activation_token"], 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 35afd52..2843c75 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -32,7 +32,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "username", email: "user@email.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, From 8ad68b7bfb7cfd69f9931cb5c5c16a395ce27dcb Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Fri, 24 Jul 2026 18:00:40 +0000 Subject: [PATCH 03/21] fix: handle empty email list in `orchestrator.getLastEmail()` --- tests/orchestrator.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 409b97d..786e1f7 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -72,9 +72,12 @@ async function deleteAllEmails() { async function getLastEmail() { const emailListResponse = await fetch(`${emailHttpUrl}/messages`); const emailListBody = await emailListResponse.json(); - const lastEmailItem = emailListBody.pop(); + if (!lastEmailItem) { + return null; + } + const emailTextResponse = await fetch( `${emailHttpUrl}/messages/${lastEmailItem.id}.plain`, ); From 65b2e9823dde47faa15c1bd2b71d9ddc238cb021 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Fri, 24 Jul 2026 18:02:58 +0000 Subject: [PATCH 04/21] feat: send activation email after `user` registration --- ...902867996_create-user-activation-tokens.js | 38 ++++++++++ infra/webserver.js | 17 +++++ models/activation.js | 76 +++++++++++++++++++ pages/api/v1/users/index.js | 6 +- .../_use-cases/registration-flow.test.js | 25 ++++-- 5 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 infra/migrations/1784902867996_create-user-activation-tokens.js create mode 100644 infra/webserver.js create mode 100644 models/activation.js diff --git a/infra/migrations/1784902867996_create-user-activation-tokens.js b/infra/migrations/1784902867996_create-user-activation-tokens.js new file mode 100644 index 0000000..ce9a5c6 --- /dev/null +++ b/infra/migrations/1784902867996_create-user-activation-tokens.js @@ -0,0 +1,38 @@ +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..f9386a6 --- /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://clone-tabnews-lime-psi.vercel.app"; +} + +const webserver = { + origin: getOrigin(), +}; + +export default webserver; diff --git a/models/activation.js b/models/activation.js new file mode 100644 index 0000000..da5a9ce --- /dev/null +++ b/models/activation.js @@ -0,0 +1,76 @@ +import database from "infra/database.js"; +import email from "infra/email.js"; +import webserver from "infra/webserver.js"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 Minutes + +async function findOneByUserId(userId) { + const newToken = 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: "TabNews ", + to: user.email, + subject: "Activate your account in TabNews!", + text: `${user.username}, please activate your account by clicking the link below: + +${webserver.origin}/signup/activate/${activationToken.id} + +If you didn't request this email, you can ignore it. + +Thanks, +TabNews Team + `, + }); +} + +const activation = { + findOneByUserId, + create, + sendEmailToUser, +}; + +export default activation; diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index ce44816..d393bbb 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -1,7 +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(); router.post(postHandler); @@ -11,5 +11,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 99899f5..01dc3ba 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.js"; import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { @@ -8,6 +9,8 @@ beforeAll(async () => { }); describe("Use case: Registration Flow (all successful)", () => { + let createUserResponseBody; + test("Create user acount", async () => { const createUserResponse = await fetch( "http://localhost:3000/api/v1/users", @@ -26,7 +29,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createUserResponse.status).toBe(201); - const createUserResponseBody = await createUserResponse.json(); + createUserResponseBody = await createUserResponse.json(); expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, @@ -39,11 +42,23 @@ describe("Use case: Registration Flow (all successful)", () => { }); }); - test("Receive activation email", async () => {}); + test("Receive 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("Activate your account in TabNews!"); + expect(lastEmail.text).toContain("RegistrationFlow"); + expect(lastEmail.text).toContain(activationToken.id); + }); - test("Activate account", async () => {}); + test.todo("Activate account"); - test("Login", async () => {}); + test.todo("Login"); - test("Get user information", async () => {}); + test.todo("Get user information"); }); From 5321068f701f4fb973a63ccdd325cb94877db11a Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Sat, 25 Jul 2026 12:38:09 +0000 Subject: [PATCH 05/21] feat: add `activation.findOneValidById()` and `orchestrator.extractUUID()` --- models/activation.js | 39 ++++++++++++------- .../_use-cases/registration-flow.test.js | 18 ++++++--- tests/orchestrator.js | 6 +++ 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/models/activation.js b/models/activation.js index da5a9ce..f18d6e1 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,28 +1,39 @@ import database from "infra/database.js"; import email from "infra/email.js"; +import { NotFoundError } from "infra/errors.js"; import webserver from "infra/webserver.js"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 Minutes -async function findOneByUserId(userId) { - const newToken = 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 - * - FROM - user_activation_tokens - WHERE - user_id = $1 - LIMIT - 1 + SELECT + * + FROM + user_activation_tokens + WHERE + 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: "The activation token was not found or expired.", + action: "Sign up again.", + }); + } + return results.rows[0]; } } @@ -68,7 +79,7 @@ TabNews Team } 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 01dc3ba..0e5b6f5 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.js"; import activation from "models/activation.js"; import orchestrator from "tests/orchestrator.js"; @@ -45,15 +46,22 @@ describe("Use case: Registration Flow (all successful)", () => { test("Receive 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("Activate your account in TabNews!"); expect(lastEmail.text).toContain("RegistrationFlow"); - expect(lastEmail.text).toContain(activationToken.id); + + const activationTokenId = orchestrator.extractUUID(lastEmail.text); + + expect(lastEmail.text).toContain( + `${webserver.origin}/signup/activate/${activationTokenId}`, + ); + + const activationTokenObject = + await activation.findOneValidById(activationTokenId); + + expect(activationTokenObject.user_id).toBe(createUserResponseBody.id); + expect(activationTokenObject.used_at).toBe(null); }); test.todo("Activate account"); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 786e1f7..4250647 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -87,6 +87,11 @@ async function getLastEmail() { return lastEmailItem; } +function extractUUID(text) { + const match = text.match(/[0-9a-fA-F-]{36}/); + return match ? match[0] : null; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -95,6 +100,7 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, }; export default orchestrator; From fcb507d6615ec13d4a7b0ae56f19e1bc35bf68a9 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Sat, 25 Jul 2026 15:02:31 +0000 Subject: [PATCH 06/21] feat: add `PATCH` `/api/v1/activations/[token_id]` --- models/activation.js | 34 ++++++++++++++++++- models/user.js | 25 ++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 22 ++++++++++++ .../_use-cases/registration-flow.test.js | 20 +++++++++-- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 pages/api/v1/activations/[token_id]/index.js diff --git a/models/activation.js b/models/activation.js index f18d6e1..c59089c 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,7 +1,8 @@ import database from "infra/database.js"; import email from "infra/email.js"; -import { NotFoundError } from "infra/errors.js"; import webserver from "infra/webserver.js"; +import user from "models/user.js"; +import { NotFoundError } from "infra/errors.js"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 Minutes @@ -38,6 +39,35 @@ async function findOneValidById(tokenId) { } } +async function markTokenAsUsed(activationTokenId) { + const usedActivationTokenObject = await runUpdateQuery(activationTokenId); + return usedActivationTokenObject; + + 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 create(userId) { const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); @@ -80,6 +110,8 @@ TabNews Team const activation = { findOneValidById, + markTokenAsUsed, + activateUserByUserId, create, sendEmailToUser, }; diff --git a/models/user.js b/models/user.js index ba02d2a..cb54408 100644 --- a/models/user.js +++ b/models/user.js @@ -220,12 +220,37 @@ async function hashPasswordInObject(userInputValues) { userInputValues.password = hashedPassword; } +async function setFeatures(userId, features) { + const updatedUser = runUpdateQuery(userId); + return updatedUser; + + async function runUpdateQuery(userId) { + 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..1b4a42c --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,22 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.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 0e5b6f5..173efed 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,5 +1,6 @@ import webserver from "infra/webserver.js"; import activation from "models/activation.js"; +import user from "models/user.js"; import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { @@ -11,6 +12,7 @@ beforeAll(async () => { describe("Use case: Registration Flow (all successful)", () => { let createUserResponseBody; + let activationTokenId; test("Create user acount", async () => { const createUserResponse = await fetch( @@ -51,7 +53,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(lastEmail.subject).toBe("Activate your account in TabNews!"); expect(lastEmail.text).toContain("RegistrationFlow"); - const activationTokenId = orchestrator.extractUUID(lastEmail.text); + activationTokenId = orchestrator.extractUUID(lastEmail.text); expect(lastEmail.text).toContain( `${webserver.origin}/signup/activate/${activationTokenId}`, @@ -64,7 +66,21 @@ describe("Use case: Registration Flow (all successful)", () => { expect(activationTokenObject.used_at).toBe(null); }); - test.todo("Activate account"); + 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.todo("Login"); From 309784d0b7df7f5921adde0b132754de95f5d880 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Sun, 26 Jul 2026 12:47:47 +0000 Subject: [PATCH 07/21] feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/sessions` --- infra/controller.js | 57 ++++++++++++++++++- infra/errors.js | 21 +++++++ pages/api/v1/sessions/index.js | 3 +- .../_use-cases/registration-flow.test.js | 22 ++++++- 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 2499982..f396c82 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,11 +1,13 @@ import * as cookie from "cookie"; import session from "models/session.js"; +import user from "models/user.js"; import { InternalServerError, MethodNotAllowedError, ValidationError, NotFoundError, UnauthorizedError, + ForbiddenError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -14,7 +16,11 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { - if (error instanceof ValidationError || error instanceof NotFoundError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ForbiddenError + ) { return response.status(error.statusCode).json(error); } @@ -54,6 +60,53 @@ async function clearSessionCookie(response) { response.setHeader("Set-Cookie", setCookie); } +async function injectAnonymousOrUser(request, response, next) { + if (request.cookies?.session_id) { + await injectAuthenticatedUser(request); + } else { + injectAnonymousUser(request); + } + + return next(); +} + +async function injectAuthenticatedUser(request) { + const sessionToken = request.cookies.session_id; + const sessionObject = await session.findOneValidByToken(sessionToken); + const userObject = await user.findOneById(sessionObject.user_id); + + request.context = { + ...request.context, + user: userObject, + }; +} + +function injectAnonymousUser(request) { + const anonymousUserObject = { + features: ["read:activation_token", "create:session", "create:user"], + }; + + request.context = { + ...request.context, + user: anonymousUserObject, + }; +} + +function canRequest(feature) { + return function canRequestMiddleware(request, response, next) { + const userTryingToRequest = request.context.user; + + if (userTryingToRequest.features.includes(feature)) { + return next(); + } + + throw new ForbiddenError({ + message: "You do not have permission to execute this action.", + action: `Check if your user has the feature ${feature}`, + }); + }; +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, @@ -61,6 +114,8 @@ const controller = { }, setSessionCookie, clearSessionCookie, + injectAnonymousOrUser, + canRequest, }; export default controller; diff --git a/infra/errors.js b/infra/errors.js index c3ea8a3..7f83284 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -79,6 +79,27 @@ export class ValidationError extends Error { } } +export class ForbiddenError extends Error { + constructor({ cause, message, action }) { + super(message || "Access Denied", { + cause, + }); + + this.name = "ForbiddenError"; + this.action = action || "Check require features before continue."; + this.statusCode = 403; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} + export class UnauthorizedError extends Error { constructor({ cause, message, action }) { super(message || "User not authenticated", { diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 95c5ecb..ea00974 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 173efed..4bc685f 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -82,7 +82,27 @@ describe("Use case: Registration Flow (all successful)", () => { expect(activatedUser.features).toEqual(["create:session"]); }); - test.todo("Login"); + 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", + password: "RegistrationFlowPassword", + }), + }, + ); + + expect(createSessionResponse.status).toBe(201); + + const createSessionResponseBody = await createSessionResponse.json(); + + expect(createSessionResponseBody.user_id).toBe(createUserResponseBody.id); + }); test.todo("Get user information"); }); From 7c991541dc72310df85a1f85cafa575c45dc2fca Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Tue, 28 Jul 2026 13:15:02 +0000 Subject: [PATCH 08/21] feat: create `authorization` model and use it in `/sessions` controller --- infra/controller.js | 3 ++- models/authorization.js | 15 +++++++++++++++ pages/api/v1/sessions/index.js | 9 +++++++++ tests/integration/api/v1/sessions/post.test.js | 2 ++ tests/orchestrator.js | 6 ++++++ 5 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 models/authorization.js diff --git a/infra/controller.js b/infra/controller.js index f396c82..12e475f 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, MethodNotAllowedError, @@ -96,7 +97,7 @@ function canRequest(feature) { return function canRequestMiddleware(request, response, next) { const userTryingToRequest = request.context.user; - if (userTryingToRequest.features.includes(feature)) { + if (authorization.can(userTryingToRequest, feature)) { return next(); } diff --git a/models/authorization.js b/models/authorization.js new file mode 100644 index 0000000..943e461 --- /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; diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index ea00974..5402745 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,7 +1,9 @@ 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(); @@ -19,6 +21,13 @@ async function postHandler(request, response) { userInputValues.password, ); + if (!authorization.can(authenticatedUser, "create:session")) { + throw new ForbiddenError({ + message: "You do not have permission to login.", + action: "Contact support if you believe this is a mistake.", + }); + } + 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 ec44405..0ccf408 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: "everythingcorrect", }); + 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 4250647..cbe08d2 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -4,6 +4,7 @@ import database from "infra/database.js"; import migrator from "models/migrator.js"; import user from "models/user.js"; import session from "models/session.js"; +import activation from "models/activation.js"; const emailHttpUrl = `http://${process.env.EMAIL_HTTP_HOST}:${process.env.EMAIL_HTTP_PORT}`; @@ -87,6 +88,10 @@ async function getLastEmail() { return lastEmailItem; } +async function activateUser(inactiveUser) { + return await activation.activateUserByUserId(inactiveUser.id); +} + function extractUUID(text) { const match = text.match(/[0-9a-fA-F-]{36}/); return match ? match[0] : null; @@ -100,6 +105,7 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + activateUser, extractUUID, }; From 7ce7955d3de6b400be9eb0da25f63edc2cc7474b Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Tue, 28 Jul 2026 14:49:53 +0000 Subject: [PATCH 09/21] feat: require `read:session` to access `/user` endpoint --- infra/controller.js | 2 +- models/activation.js | 5 +++- pages/api/v1/user/index.js | 3 ++- .../_use-cases/registration-flow.test.js | 17 +++++++++--- tests/integration/api/v1/user/get.test.js | 27 ++++++++++++++++--- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 12e475f..b748df8 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -103,7 +103,7 @@ function canRequest(feature) { throw new ForbiddenError({ message: "You do not have permission to execute this action.", - action: `Check if your user has the feature ${feature}`, + action: `Check if your user has the feature "${feature}"`, }); }; } diff --git a/models/activation.js b/models/activation.js index c59089c..3840845 100644 --- a/models/activation.js +++ b/models/activation.js @@ -64,7 +64,10 @@ async function markTokenAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { - const activatedUser = await user.setFeatures(userId, ["create:session"]); + const activatedUser = await user.setFeatures(userId, [ + "create:session", + "read:session", + ]); return activatedUser; } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index 1111a73..f5e1b85 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -5,7 +5,8 @@ import user from "models/user.js"; const router = createRouter(); -router.get(getHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:session"), getHandler); 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 4bc685f..c1cc5bf 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 successful)", () => { let createUserResponseBody; let activationTokenId; + let createSessionResponseBody; test("Create user acount", async () => { const createUserResponse = await fetch( @@ -79,7 +80,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); const activatedUser = await user.findOneByUsername("RegistrationFlow"); - expect(activatedUser.features).toEqual(["create:session"]); + expect(activatedUser.features).toEqual(["create:session", "read:session"]); }); test("Login", async () => { @@ -99,10 +100,20 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createSessionResponse.status).toBe(201); - const createSessionResponseBody = await createSessionResponse.json(); + createSessionResponseBody = await createSessionResponse.json(); expect(createSessionResponseBody.user_id).toBe(createUserResponseBody.id); }); - test.todo("Get user information"); + test("Get user information", async () => { + const userDataResponse = await fetch("http://localhost:3000/api/v1/user", { + headers: { + cookie: `session_id=${createSessionResponseBody.token}`, + }, + }); + expect(userDataResponse.status).toBe(200); + + const userDataResponseBody = await userDataResponse.json(); + expect(userDataResponseBody.id).toBe(createUserResponseBody.id); + }); }); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 35f04d2..77795eb 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -10,12 +10,29 @@ beforeAll(async () => { }); describe("GET /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: "You do not have permission to execute this action.", + action: `Check if your user has the 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", { @@ -37,10 +54,10 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithValidSession", email: createdUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session"], password: createdUser.password, created_at: createdUser.created_at.toISOString(), - updated_at: createdUser.updated_at.toISOString(), + updated_at: activatedUser.updated_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toBe(4); @@ -86,6 +103,8 @@ describe("GET /api/v1/user", () => { username: "UserWith1MinuteValidSession", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(createdUser.id); jest.useRealTimers(); @@ -109,10 +128,10 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWith1MinuteValidSession", email: createdUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session"], password: createdUser.password, created_at: createdUser.created_at.toISOString(), - updated_at: createdUser.updated_at.toISOString(), + updated_at: activatedUser.updated_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toBe(4); From 143d3fc6652644da6bab244d34170dde256fa542 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 30 Jul 2026 15:14:40 +0000 Subject: [PATCH 10/21] test: makes `orchestrator.createUser()` use optional chaining --- tests/integration/api/v1/sessions/post.test.js | 4 ++-- tests/orchestrator.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 0ccf408..5032602 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -68,7 +68,7 @@ describe("POST /api/v1/sessions", () => { }); test("With incorrect `email` and incorrect `password`", async () => { - await orchestrator.createUser({}); + await orchestrator.createUser(); const response = await fetch("http://localhost:3000/api/v1/sessions", { method: "POST", @@ -99,7 +99,7 @@ describe("POST /api/v1/sessions", () => { password: "everythingcorrect", }); - orchestrator.activateUser(createdUser); + await orchestrator.activateUser(createdUser); const response = await fetch("http://localhost:3000/api/v1/sessions", { method: "POST", diff --git a/tests/orchestrator.js b/tests/orchestrator.js index cbe08d2..d730388 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -54,9 +54,9 @@ async function runPendingMigrations() { async function createUser(userObject) { return await user.create({ username: - userObject.username || faker.internet.username().replace(/[_.-]/g, ""), - email: userObject.email || faker.internet.email(), - password: userObject.password || "validpassword", + userObject?.username || faker.internet.username().replace(/[_.-]/g, ""), + email: userObject?.email || faker.internet.email(), + password: userObject?.password || "validpassword", }); } From 657cf66702c1d3c2b64add7555e528ecea73b700 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 30 Jul 2026 15:17:46 +0000 Subject: [PATCH 11/21] feat: require `read:activation_token` to access `/api/v1/activations/[token_id]` --- models/activation.js | 14 +- pages/api/v1/activations/[token_id]/index.js | 8 +- .../v1/activations/[token_id]/patch.test.js | 200 ++++++++++++++++++ 3 files changed, 218 insertions(+), 4 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 3840845..18aa0e2 100644 --- a/models/activation.js +++ b/models/activation.js @@ -2,7 +2,8 @@ import database from "infra/database.js"; import email from "infra/email.js"; import webserver from "infra/webserver.js"; import user from "models/user.js"; -import { NotFoundError } from "infra/errors.js"; +import authorization from "./authorization.js"; +import { ForbiddenError, NotFoundError } from "infra/errors.js"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 Minutes @@ -64,10 +65,20 @@ async function markTokenAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { + const userToActivate = await user.findOneById(userId); + + if (!authorization.can(userToActivate, "read:activation_token")) { + throw new ForbiddenError({ + message: "You cannot use activation token anymore.", + action: "Contact support.", + }); + } + const activatedUser = await user.setFeatures(userId, [ "create:session", "read:session", ]); + return activatedUser; } @@ -117,6 +128,7 @@ const activation = { activateUserByUserId, create, sendEmailToUser, + EXPIRATION_IN_MILLISECONDS, }; export default activation; diff --git a/pages/api/v1/activations/[token_id]/index.js b/pages/api/v1/activations/[token_id]/index.js index 1b4a42c..0203a4e 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); @@ -13,10 +14,11 @@ async function patchHandler(request, response) { const validActivationToken = await activation.findOneValidById(activationTokenId); - const usedActivationToken = - await activation.markTokenAsUsed(activationTokenId); await activation.activateUserByUserId(validActivationToken.user_id); + const usedActivationToken = + await activation.markTokenAsUsed(activationTokenId); + return response.status(200).json(usedActivationToken); } diff --git a/tests/integration/api/v1/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js new file mode 100644 index 0000000..ecb0940 --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,200 @@ +// import { version as uuidVersion } from "uuid"; +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js"; +import activation from "models/activation.js"; +import { getExpectedRequestStore } from "next/dist/client/components/request-async-storage.external"; + +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/1516e505-9f15-4fb8-ad98-fb0775f47459", + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "The activation token was not found or expired.", + action: "Sign up again.", + 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: "The activation token was not found or expired.", + action: "Sign up again.", + 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: "The activation token was not found or expired.", + action: "Sign up again.", + 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: responseBody.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(); + + const expires_at = new Date(responseBody.expires_at); + const created_at = new Date(responseBody.created_at); + + expires_at.setMilliseconds(0); + created_at.setMilliseconds(0); + + expect(expires_at - created_at).toBe( + activation.EXPIRATION_IN_MILLISECONDS, + ); + + const activatedUser = await user.findOneById(responseBody.user_id); + expect(activatedUser.features).toEqual([ + "create:session", + "read:session", + ]); + }); + + test("With valid token but already activated user", async () => { + 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}`, + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You cannot use activation token anymore.", + action: "Contact support.", + status_code: 403, + }); + }); + }); + + describe("Default user", () => { + test("With valid token, but already logged user", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1Session = 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=${user1Session.token}`, + }, + }, + ); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to execute this action.", + action: 'Check if your user has the feature "read:activation_token"', + status_code: 403, + }); + }); + }); +}); From a32639e456225b6dd2e71b61f0eedcb6ce3fed19 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Fri, 31 Jul 2026 13:52:17 +0000 Subject: [PATCH 12/21] feat: require `create:user` to access `api/v1/users` --- pages/api/v1/users/index.js | 3 ++- tests/integration/api/v1/users/post.test.js | 29 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index d393bbb..c808af5 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -4,7 +4,8 @@ import user from "models/user.js"; 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/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 2843c75..b4b2efe 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -135,4 +135,33 @@ 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 user1Session = 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=${user1Session.token}`, + }, + body: JSON.stringify({ + username: "loggeduser", + email: "loggeduser@email.com", + password: "password123", + }), + }); + expect(user2Response.status).toBe(403); + + const user2ResponseBody = await user2Response.json(); + expect(user2ResponseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to execute this action.", + action: 'Check if your user has the feature "create:user"', + status_code: 403, + }); + }); + }); }); From 863890c14d240de3413022a1516b275ddaf65537 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Sat, 1 Aug 2026 14:18:55 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20require=20`update:user`=20to=20ac?= =?UTF-8?q?cess=20`/api/v1/users/[username]=C2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 | 2 +- tests/integration/api/v1/user/get.test.js | 4 +- .../api/v1/users/[username]/patch.test.js | 74 +++++++++++++++++-- 6 files changed, 77 insertions(+), 13 deletions(-) diff --git a/models/activation.js b/models/activation.js index 18aa0e2..93ad463 100644 --- a/models/activation.js +++ b/models/activation.js @@ -77,6 +77,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 9cd7ef5..842ef4b 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 c1cc5bf..6d921ab 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -80,7 +80,11 @@ describe("Use case: Registration Flow (all successful)", () => { expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); const activatedUser = await user.findOneByUsername("RegistrationFlow"); - expect(activatedUser.features).toEqual(["create:session", "read:session"]); + expect(activatedUser.features).toEqual([ + "create:session", + "read:session", + "update:user", + ]); }); test("Login", async () => { diff --git a/tests/integration/api/v1/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js index ecb0940..3fb039d 100644 --- a/tests/integration/api/v1/activations/[token_id]/patch.test.js +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -3,7 +3,6 @@ import { version as uuidVersion } from "uuid"; import orchestrator from "tests/orchestrator.js"; import user from "models/user.js"; import activation from "models/activation.js"; -import { getExpectedRequestStore } from "next/dist/client/components/request-async-storage.external"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -139,6 +138,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { expect(activatedUser.features).toEqual([ "create: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 77795eb..369c6fc 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -54,7 +54,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithValidSession", 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(), @@ -128,7 +128,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWith1MinuteValidSession", 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 25a79d9..e26f33e 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: 'Check if your user has the feature "update:user"', + message: "You do not have permission to execute this action.", + 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/nonexistentUser", { 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", @@ -97,16 +146,17 @@ describe("PATCH /api/v1/users/[username]", () => { }); test("With unique 'username'", async () => { - const createdUser = await orchestrator.createUser({ - username: "uniqueUser1", - }); + 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/uniqueUser1", + `http://localhost:3000/api/v1/users/${createdUser.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser2", @@ -122,7 +172,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser2", email: createdUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -140,12 +190,16 @@ describe("PATCH /api/v1/users/[username]", () => { email: "uniqueEmail1@email.com", }); + 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}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ email: "uniqueEmail2@email.com", @@ -161,7 +215,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail2@email.com", - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -179,12 +233,16 @@ describe("PATCH /api/v1/users/[username]", () => { 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}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword2", @@ -200,7 +258,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: createdUser.email, - features: ["read:activation_token"], + features: ["create:session", "read:session", "update:user"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, From 0932894bb74ceb09502213c42874f9b1000d4276 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Mon, 3 Aug 2026 13:53:39 +0000 Subject: [PATCH 14/21] feat: consider `resource` in `authorization` model --- models/authorization.js | 10 +- pages/api/v1/users/[username]/index.js | 14 ++- .../api/v1/users/[username]/patch.test.js | 114 ++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 943e461..54c4eb5 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; } diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 842ef4b..10ed78a 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(); @@ -17,9 +19,19 @@ async function getHandler(request, response) { } async function patchHandler(request, response) { - const { username } = request.query; + 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: "You do not have permission to update another user.", + action: "Check if you have the feature to update another user.", + }); + } + 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 e26f33e..f76dfbb 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -105,6 +105,43 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); + test("With 'userB' targeting 'userA' username", async () => { + await orchestrator.createUser({ + username: "userA", + }); + + const createdUserB = await orchestrator.createUser({ + username: "userB", + }); + + const activatedUserB = await orchestrator.activateUser(createdUserB); + const sessionObjectB = await orchestrator.createSession( + activatedUserB.id, + ); + + const response = await fetch("http://localhost:3000/api/v1/users/userA", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObjectB.token}`, + }, + body: JSON.stringify({ + username: "userC", + }), + }); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to update another user.", + action: "Check if you have the feature to update another user.", + status_code: 403, + }); + }); + test("With duplicated 'email'", async () => { await orchestrator.createUser({ email: "email1@email.com", @@ -145,6 +182,45 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); + test("With 'userD' targeting 'userC' email", async () => { + await orchestrator.createUser({ + username: "userc", + email: "userc@email.com", + }); + + const createdUserD = await orchestrator.createUser({ + username: "userd", + email: "userd@email.com", + }); + + const activatedUserD = await orchestrator.activateUser(createdUserD); + const sessionObjectD = await orchestrator.createSession( + activatedUserD.id, + ); + + const response = await fetch(`http://localhost:3000/api/v1/users/userc`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObjectD.token}`, + }, + body: JSON.stringify({ + email: "usere@email.com", + }), + }); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to update another user.", + action: "Check if you have the feature to update another user.", + status_code: 403, + }); + }); + test("With unique 'username'", async () => { const createdUser = await orchestrator.createUser(); const activatedUser = await orchestrator.activateUser(createdUser); @@ -284,5 +360,43 @@ describe("PATCH /api/v1/users/[username]", () => { expect(correctPasswordMatch).toBe(true); expect(incorrectPasswordMatch).toBe(false); }); + + test("With user 'beta' targeting user 'alpha' password", async () => { + await orchestrator.createUser({ + username: "alpha", + }); + + const createdUserBeta = await orchestrator.createUser({ + username: "beta", + }); + + const activatedUserBeta = + await orchestrator.activateUser(createdUserBeta); + const sessionObjectBeta = await orchestrator.createSession( + activatedUserBeta.id, + ); + + const response = await fetch(`http://localhost:3000/api/v1/users/alpha`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObjectBeta.token}`, + }, + body: JSON.stringify({ + password: "newPasswordForUserAlpha", + }), + }); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to update another user.", + action: "Check if you have the feature to update another user.", + status_code: 403, + }); + }); }); }); From 71cd21fd15d18bdb1dfe52b0e39f8a96f9789fd7 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Tue, 4 Aug 2026 12:50:03 +0000 Subject: [PATCH 15/21] feat: allow `update:user:others` to update other users --- models/authorization.js | 2 +- models/user.js | 25 +++ .../api/v1/users/[username]/patch.test.js | 150 ++++++++---------- tests/orchestrator.js | 6 + 4 files changed, 95 insertions(+), 88 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 54c4eb5..43f7ff9 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 cb54408..b4c1f5f 100644 --- a/models/user.js +++ b/models/user.js @@ -244,6 +244,30 @@ async function setFeatures(userId, features) { } } +async function addFeatures(userId, features) { + const updatedUser = runUpdateQuery(userId); + return updatedUser; + + async function runUpdateQuery(userId) { + 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, @@ -251,6 +275,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 f76dfbb..aaed8c3 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -105,43 +105,6 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); - test("With 'userB' targeting 'userA' username", async () => { - await orchestrator.createUser({ - username: "userA", - }); - - const createdUserB = await orchestrator.createUser({ - username: "userB", - }); - - const activatedUserB = await orchestrator.activateUser(createdUserB); - const sessionObjectB = await orchestrator.createSession( - activatedUserB.id, - ); - - const response = await fetch("http://localhost:3000/api/v1/users/userA", { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `session_id=${sessionObjectB.token}`, - }, - body: JSON.stringify({ - username: "userC", - }), - }); - - expect(response.status).toBe(403); - - const responseBody = await response.json(); - - expect(responseBody).toEqual({ - name: "ForbiddenError", - message: "You do not have permission to update another user.", - action: "Check if you have the feature to update another user.", - status_code: 403, - }); - }); - test("With duplicated 'email'", async () => { await orchestrator.createUser({ email: "email1@email.com", @@ -182,45 +145,6 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); - test("With 'userD' targeting 'userC' email", async () => { - await orchestrator.createUser({ - username: "userc", - email: "userc@email.com", - }); - - const createdUserD = await orchestrator.createUser({ - username: "userd", - email: "userd@email.com", - }); - - const activatedUserD = await orchestrator.activateUser(createdUserD); - const sessionObjectD = await orchestrator.createSession( - activatedUserD.id, - ); - - const response = await fetch(`http://localhost:3000/api/v1/users/userc`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Cookie: `session_id=${sessionObjectD.token}`, - }, - body: JSON.stringify({ - email: "usere@email.com", - }), - }); - - expect(response.status).toBe(403); - - const responseBody = await response.json(); - - expect(responseBody).toEqual({ - name: "ForbiddenError", - message: "You do not have permission to update another user.", - action: "Check if you have the feature to update another user.", - status_code: 403, - }); - }); - test("With unique 'username'", async () => { const createdUser = await orchestrator.createUser(); const activatedUser = await orchestrator.activateUser(createdUser); @@ -361,29 +285,29 @@ describe("PATCH /api/v1/users/[username]", () => { expect(incorrectPasswordMatch).toBe(false); }); - test("With user 'beta' targeting user 'alpha' password", async () => { + test("With 'userB' targeting 'userA' username", async () => { await orchestrator.createUser({ - username: "alpha", + username: "userA", }); - const createdUserBeta = await orchestrator.createUser({ - username: "beta", + const createdUserB = await orchestrator.createUser({ + username: "userB", }); - const activatedUserBeta = - await orchestrator.activateUser(createdUserBeta); - const sessionObjectBeta = await orchestrator.createSession( - activatedUserBeta.id, + const activatedPrivilegedUser = + await orchestrator.activateUser(createdUserB); + const privilegedUserSession = await orchestrator.createSession( + activatedPrivilegedUser.id, ); - const response = await fetch(`http://localhost:3000/api/v1/users/alpha`, { + const response = await fetch("http://localhost:3000/api/v1/users/userA", { method: "PATCH", headers: { "Content-Type": "application/json", - Cookie: `session_id=${sessionObjectBeta.token}`, + Cookie: `session_id=${privilegedUserSession.token}`, }, body: JSON.stringify({ - password: "newPasswordForUserAlpha", + username: "userC", }), }); @@ -399,4 +323,56 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); }); + + 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: "updatedByPrivilegedUser", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: defaultUser.id, + username: "updatedByPrivilegedUser", + 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 d730388..4c86181 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -97,6 +97,11 @@ function extractUUID(text) { return match ? match[0] : null; } +async function addFeaturesToUser(userObject, features) { + const updatedUser = await user.addFeatures(userObject.id, features); + return updatedUser; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -107,6 +112,7 @@ const orchestrator = { getLastEmail, activateUser, extractUUID, + addFeaturesToUser, }; export default orchestrator; From f08f88a641ef6622df6e473938f77dfc29b24ddb Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Mon, 10 Aug 2026 11:18:13 +0000 Subject: [PATCH 16/21] feat: apply `authorization.filterOutput()` to all endpoints --- models/authorization.js | 79 +++++++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 10 ++- pages/api/v1/migrations/index.js | 29 ++++++- pages/api/v1/sessions/index.js | 17 +++- pages/api/v1/status/index.js | 15 +++- pages/api/v1/user/index.js | 11 ++- 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 | 50 +++++++++++- .../api/v1/migrations/post.test.js | 76 +++++++++++------- tests/integration/api/v1/status/get.test.js | 22 +++++- tests/integration/api/v1/user/get.test.js | 2 - .../api/v1/users/[username]/get.test.js | 4 - .../api/v1/users/[username]/patch.test.js | 8 -- tests/integration/api/v1/users/post.test.js | 2 - 16 files changed, 294 insertions(+), 62 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 43f7ff9..1d75f45 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 0203a4e..d767984 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 = @@ -20,5 +22,11 @@ async function patchHandler(request, response) { const usedActivationToken = await activation.markTokenAsUsed(activationTokenId); - return response.status(200).json(usedActivationToken); + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:activation_token", + usedActivationToken, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/migrations/index.js b/pages/api/v1/migrations/index.js index a988063..d91215b 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -1,25 +1,46 @@ 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(); if (migratedMigrations.length > 0) { return response.status(201).json(migratedMigrations); } - return response.status(200).json(migratedMigrations); + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:migration", + migratedMigrations, + ); + + if (migratedMigrations.length > 0) { + return response.status(201).json(secureOutputValues); + } + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 5402745..2159a68 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -32,10 +32,17 @@ 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); @@ -43,5 +50,11 @@ async function deleteHandler(request, response) { 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 cc045fe..645f627 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -1,14 +1,17 @@ import { createRouter } from "next-connect"; import controller from "infra/controller.js"; import database from "infra/database.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;"); @@ -28,7 +31,7 @@ async function getHandler(request, response) { const databaseOpenedConnectionsValue = databaseOpenedConnectionsResult.rows[0].count; - return response.status(200).json({ + const statusObject = { updated_at: updatedAt, dependencies: { database: { @@ -37,5 +40,13 @@ async function getHandler(request, response) { opened_connections: databaseOpenedConnectionsValue, }, }, - }); + }; + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:status", + statusObject, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index f5e1b85..40834ec 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 session from "models/session.js"; import user from "models/user.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); @@ -23,5 +25,12 @@ async function getHandler(request, response) { "Cache-Control", "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 10ed78a..576e104 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -13,9 +13,16 @@ router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { - const { username } = request.query; + 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) { @@ -33,5 +40,11 @@ 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 c808af5..6e97bf2 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(); router.use(controller.injectAnonymousOrUser); @@ -10,11 +11,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 6d921ab..c2d6475 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -38,9 +38,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, username: "RegistrationFlow", - email: "registration.flow@email.com", features: ["read:activation_token"], - password: createUserResponseBody.password, 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..9e3eae5 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -3,18 +3,64 @@ 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(200); + expect(response.status).toBe(403); const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to execute this action.", + action: `Check if your user has the 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: "You do not have permission to execute this action.", + action: `Check if your user has the feature "read:migration"`, + status_code: 403, + }); + }); + }); + + describe("Privileged user", () => { + test("Retrieving pending migrations", async () => { + const createdUser = await orchestrator.createUser(); + const activatedUser = await orchestrator.activateUser(createdUser); + 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 5780cbc..3ce7211 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,39 +3,61 @@ 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 response = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response.status).toBe(201); - - const responseBody = await response.json(); - - expect(Array.isArray(responseBody)).toBe(true); - expect(responseBody.length).toBeGreaterThan(0); + test("Running 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: "You do not have permission to execute this action.", + action: `Check if your user has the feature "create:migration"`, + status_code: 403, + }); + }); + }); + + describe("Default user", () => { + test("Running pending migrations", async () => { + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + }); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "You do not have permission to execute this action.", + action: `Check if your user has the feature "create:migration"`, + status_code: 403, }); }); }); + + describe("Privileged user", () => { + test("Running pending migrations", 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 c2ae51b..ec1dda9 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -11,10 +11,30 @@ describe("GET /api/v1/status", () => { 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).not.toHaveProperty("version"); + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.opened_connections).toEqual(1); + }); + }); + describe("Privileged user", () => { + test("Retrieving current system status", async () => { + const createdUser = await orchestrator.createUser(); + const activatedUser = await orchestrator.activateUser(createdUser); + orchestrator.addFeaturesToUser(createdUser, ["read:status:all"]); + const sessionObject = await orchestrator.createSession(activatedUser.id); + + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + 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.6"); 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 369c6fc..f35bfa9 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -55,7 +55,6 @@ describe("GET /api/v1/user", () => { username: "UserWithValidSession", 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(), }); @@ -129,7 +128,6 @@ describe("GET /api/v1/user", () => { username: "UserWith1MinuteValidSession", 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 3881293..561f565 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -27,9 +27,7 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "SameCase", - email: "same.case@email.com", features: ["read:activation_token"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -57,9 +55,7 @@ describe("GET /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "DifferentCase", - email: "different.case@email.com", features: ["read:activation_token"], - password: responseBody.password, 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 aaed8c3..935fdde 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -171,9 +171,7 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "uniqueUser2", - email: createdUser.email, features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -214,9 +212,7 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: "uniqueEmail2@email.com", features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -257,9 +253,7 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: responseBody.id, username: createdUser.username, - email: createdUser.email, features: ["create:session", "read:session", "update:user"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -361,9 +355,7 @@ describe("PATCH /api/v1/users/[username]", () => { expect(responseBody).toEqual({ id: defaultUser.id, username: "updatedByPrivilegedUser", - 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 b4b2efe..73e949c 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,9 +31,7 @@ describe("POST /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "username", - email: "user@email.com", features: ["read:activation_token"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); From 8225a68f1c26ca2cb06cbc100a1ef670a53bcd26 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Wed, 12 Aug 2026 12:21:48 +0000 Subject: [PATCH 17/21] feat: validate `user`, `feature` and `resource` in `authorization` model --- models/authorization.js | 59 +++++++++++++++ tests/unit/models/authorization.test.js | 95 +++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/unit/models/authorization.test.js diff --git a/models/authorization.js b/models/authorization.js index 1d75f45..4011b1a 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: "It is necessary to provide `user` in model `authorization`.", + }); + } +} + +function validateFeature(feature) { + if (!feature || !availableFeatures.includes(feature)) { + throw new InternalServerError({ + cause: + "It is necessary to provide a known `feature` in model `authorization`.", + }); + } +} + +function validateResource(resource) { + if (!resource) { + throw new InternalServerError({ + cause: + "It is necessary to provide a `resource` in model `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..5f1e051 --- /dev/null +++ b/tests/unit/models/authorization.test.js @@ -0,0 +1,95 @@ +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("without 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("without 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, "create:user"), + ).toThrow(InternalServerError); + }); + + test("with valid `user`, known `feature` and `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + const resource = { + id: 1, + username: "resource", + email: "resource.email.com", + password: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + updated_at: "2026-0101T00:00:00.000Z", + }; + + const results = authorization.filterOutput( + createdUser, + "read:user", + resource, + ); + expect(results).toEqual({ + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + updated_at: "2026-0101T00:00:00.000Z", + }); + }); + }); +}); From 0794b9080b7a96db661c7d52a92aa32546d0765a Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 13 Aug 2026 12:26:16 +0000 Subject: [PATCH 18/21] chore: update Node.js version to 24 --- .nvmrc | 2 +- package-lock.json | 16 +++------------- package.json | 3 +++ 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/.nvmrc b/.nvmrc index a77793e..cabf43b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/hydrogen +24 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 84ca26b..00fdd66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,9 @@ "jest": "29.7.0", "prettier": "3.3.3", "set-cookie-parser": "2.7.1" + }, + "engines": { + "node": "24" } }, "node_modules/@ampproject/remapping": { @@ -2266,19 +2269,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/pg": { - "version": "8.15.4", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", - "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", diff --git a/package.json b/package.json index 7a7a5b6..654c3f3 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "engines": { + "node": "24" } } From 0faaa18aac1ecf0000b275772b38f5542734070f Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 13 Aug 2026 12:27:22 +0000 Subject: [PATCH 19/21] 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 d2c7e6d..608d384 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 a3d187727b550cd25019024bc2a7c6b6785e4252 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Thu, 13 Aug 2026 12:52:47 +0000 Subject: [PATCH 20/21] chore: add `migrations:up:dry` npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 654c3f3..d458520 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,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 026229bd90fdd33885218232c3cf011b9e263c95 Mon Sep 17 00:00:00 2001 From: Adriano Garcia Date: Fri, 14 Aug 2026 12:25:49 +0000 Subject: [PATCH 21/21] refactor: improve error logging in `email.send()` --- infra/email.js | 12 +++++++++++- infra/errors.js | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/infra/email.js b/infra/email.js index e977efe..f58b12d 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: "It was not possible to send the email.", + action: "Check if the email service is avaiable.", + cause: error, + context: mailOptions, + }); + } } const email = { diff --git a/infra/errors.js b/infra/errors.js index 7f83284..fdbc210 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -39,14 +39,15 @@ export class MethodNotAllowedError extends Error { } export class ServiceError extends Error { - constructor({ cause, message }) { + constructor({ cause, message, action, context }) { super(message || "Service unavailable.", { cause, }); this.name = "ServiceError"; - this.action = "Check if the service is available."; + this.action = action || "Check if the service is available."; this.statusCode = 503; + this.context = context; } toJSON() { @@ -55,6 +56,7 @@ export class ServiceError extends Error { message: this.message, action: this.action, status_code: this.statusCode, + context: this.context, }; } }