From 6efb81ab8eb1e344ecf4dbfa3c9c6771058fe74f Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Fri, 16 Jan 2026 09:24:59 +0000 Subject: [PATCH 01/23] chore: rename folder `test` to `tests` --- {test => tests}/integration/api/v1/migrations/delete.test.js | 2 +- {test => tests}/integration/api/v1/migrations/get.test.js | 2 +- {test => tests}/integration/api/v1/migrations/post.test.js | 2 +- {test => tests}/integration/api/v1/sessions/delete.test.js | 2 +- {test => tests}/integration/api/v1/sessions/post.test.js | 2 +- {test => tests}/integration/api/v1/status/get.test.js | 2 +- {test => tests}/integration/api/v1/status/post.test.js | 2 +- {test => tests}/integration/api/v1/user/get.test.js | 2 +- {test => tests}/integration/api/v1/users/[username]/get.test.js | 2 +- .../integration/api/v1/users/[username]/patch.test.js | 2 +- {test => tests}/integration/api/v1/users/post.test.js | 2 +- {test => tests}/integration/infra/email.test.js | 2 +- {test => tests}/orchestrator.js | 0 13 files changed, 12 insertions(+), 12 deletions(-) rename {test => tests}/integration/api/v1/migrations/delete.test.js (95%) rename {test => tests}/integration/api/v1/migrations/get.test.js (92%) rename {test => tests}/integration/api/v1/migrations/post.test.js (95%) rename {test => tests}/integration/api/v1/sessions/delete.test.js (98%) rename {test => tests}/integration/api/v1/sessions/post.test.js (98%) rename {test => tests}/integration/api/v1/status/get.test.js (96%) rename {test => tests}/integration/api/v1/status/post.test.js (95%) rename {test => tests}/integration/api/v1/user/get.test.js (99%) rename {test => tests}/integration/api/v1/users/[username]/get.test.js (98%) rename {test => tests}/integration/api/v1/users/[username]/patch.test.js (99%) rename {test => tests}/integration/api/v1/users/post.test.js (98%) rename {test => tests}/integration/infra/email.test.js (94%) rename {test => tests}/orchestrator.js (100%) diff --git a/test/integration/api/v1/migrations/delete.test.js b/tests/integration/api/v1/migrations/delete.test.js similarity index 95% rename from test/integration/api/v1/migrations/delete.test.js rename to tests/integration/api/v1/migrations/delete.test.js index 1507b68..4308a5f 100644 --- a/test/integration/api/v1/migrations/delete.test.js +++ b/tests/integration/api/v1/migrations/delete.test.js @@ -1,4 +1,4 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js similarity index 92% rename from test/integration/api/v1/migrations/get.test.js rename to tests/integration/api/v1/migrations/get.test.js index eb75704..6e6ea72 100644 --- a/test/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -1,4 +1,4 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js similarity index 95% rename from test/integration/api/v1/migrations/post.test.js rename to tests/integration/api/v1/migrations/post.test.js index a758e21..6913b52 100644 --- a/test/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -1,4 +1,4 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js similarity index 98% rename from test/integration/api/v1/sessions/delete.test.js rename to tests/integration/api/v1/sessions/delete.test.js index c8090a7..fc53abd 100644 --- a/test/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -1,7 +1,7 @@ import { version as uuidVersion } from "uuid"; import setCookieParser from "set-cookie-parser"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; import session from "models/session"; beforeAll(async () => { diff --git a/test/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js similarity index 98% rename from test/integration/api/v1/sessions/post.test.js rename to tests/integration/api/v1/sessions/post.test.js index 5b63690..ac549e6 100644 --- a/test/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -1,6 +1,6 @@ import { version as uuidVersion } from "uuid"; import setCookieParser from "set-cookie-parser"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; import session from "models/session"; beforeAll(async () => { diff --git a/test/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js similarity index 96% rename from test/integration/api/v1/status/get.test.js rename to tests/integration/api/v1/status/get.test.js index dd2dc10..d1e1956 100644 --- a/test/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -1,4 +1,4 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/status/post.test.js b/tests/integration/api/v1/status/post.test.js similarity index 95% rename from test/integration/api/v1/status/post.test.js rename to tests/integration/api/v1/status/post.test.js index 4178ae2..9f30ace 100644 --- a/test/integration/api/v1/status/post.test.js +++ b/tests/integration/api/v1/status/post.test.js @@ -1,4 +1,4 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js similarity index 99% rename from test/integration/api/v1/user/get.test.js rename to tests/integration/api/v1/user/get.test.js index 6246404..a578f73 100644 --- a/test/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -1,7 +1,7 @@ import { version as uuidVersion } from "uuid"; import setCookieParser from "set-cookie-parser"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; import session from "models/session"; beforeAll(async () => { diff --git a/test/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js similarity index 98% rename from test/integration/api/v1/users/[username]/get.test.js rename to tests/integration/api/v1/users/[username]/get.test.js index 9dfae3e..3916164 100644 --- a/test/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -1,5 +1,5 @@ import { version as uuidVersion } from "uuid"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js similarity index 99% rename from test/integration/api/v1/users/[username]/patch.test.js rename to tests/integration/api/v1/users/[username]/patch.test.js index f51f5d8..641e4c6 100644 --- a/test/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -1,5 +1,5 @@ import { version as uuidVersion } from "uuid"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; import user from "models/user"; import password from "models/password"; diff --git a/test/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js similarity index 98% rename from test/integration/api/v1/users/post.test.js rename to tests/integration/api/v1/users/post.test.js index 71ff1f5..19e30c3 100644 --- a/test/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -1,5 +1,5 @@ import { version as uuidVersion } from "uuid"; -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; import user from "models/user"; import password from "models/password"; diff --git a/test/integration/infra/email.test.js b/tests/integration/infra/email.test.js similarity index 94% rename from test/integration/infra/email.test.js rename to tests/integration/infra/email.test.js index ac72d65..0be5c1b 100644 --- a/test/integration/infra/email.test.js +++ b/tests/integration/infra/email.test.js @@ -1,5 +1,5 @@ import email from "infra/email.js"; -import orchestrator from "test/orchestrator.js"; +import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { await orchestrator.waitAllServices(); diff --git a/test/orchestrator.js b/tests/orchestrator.js similarity index 100% rename from test/orchestrator.js rename to tests/orchestrator.js From 7abe95fedc5828266f1b234cbd3fa9f847beb092 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 17:00:26 +0000 Subject: [PATCH 02/23] chore: fix typos --- infra/controller.js | 12 +++++----- .../migrations/1740080557717_create-users.js | 4 ++-- infra/scripts/wait-for-postgres.js | 4 ++-- models/password.js | 6 ++--- models/session.js | 22 +++++++++---------- models/user.js | 12 +++++----- pages/api/v1/sessions/index.js | 2 +- .../api/v1/migrations/delete.test.js | 4 ++-- .../integration/api/v1/migrations/get.test.js | 4 ++-- .../api/v1/migrations/post.test.js | 4 ++-- .../api/v1/sessions/delete.test.js | 6 ++--- .../integration/api/v1/sessions/post.test.js | 6 ++--- tests/integration/api/v1/status/get.test.js | 2 +- tests/integration/api/v1/status/post.test.js | 2 +- tests/integration/api/v1/user/get.test.js | 16 +++++++------- .../api/v1/users/[username]/get.test.js | 16 +++++++------- .../api/v1/users/[username]/patch.test.js | 6 ++--- tests/integration/api/v1/users/post.test.js | 8 +++---- 18 files changed, 68 insertions(+), 68 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 426cb86..11c0213 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -10,8 +10,8 @@ import { } from "infra/errors"; function onNoMatchHandler(request, response) { - const puclicErrorObjcet = new MethodNotAllowedError(); - response.status(puclicErrorObjcet.statusCode).json(puclicErrorObjcet); + const publicErrorObject = new MethodNotAllowedError(); + response.status(publicErrorObject.statusCode).json(publicErrorObject); } function onErrorHandler(error, request, response) { @@ -24,17 +24,17 @@ function onErrorHandler(error, request, response) { return response.status(error.statusCode).json(error); } - const puclicErrorObjcet = new InternalServerError({ + const publicErrorObject = new InternalServerError({ cause: error, }); - console.error(puclicErrorObjcet); - response.status(puclicErrorObjcet.statusCode).json(puclicErrorObjcet); + console.error(publicErrorObject); + response.status(publicErrorObject.statusCode).json(publicErrorObject); } async function setSessionCookie(sessionToken, response) { const setCookie = cookie.serialize("session_id", sessionToken, { path: "/", - maxAge: session.EXPIRATION_IN_MILISECONS / 1000, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, secure: process.env.NODE_ENV === "production", httpOnly: true, }); diff --git a/infra/migrations/1740080557717_create-users.js b/infra/migrations/1740080557717_create-users.js index 95188e9..26433b8 100644 --- a/infra/migrations/1740080557717_create-users.js +++ b/infra/migrations/1740080557717_create-users.js @@ -11,13 +11,13 @@ exports.up = (pgm) => { notNull: true, unique: true, }, - //Why 254 in lenght? https://stackoverflow.com/a/1199238 + //Why 254 in length? https://stackoverflow.com/a/1199238 email: { type: "varchar(254)", notNull: true, unique: true, }, - //Why 60 in lenght? https://www.npmjs.com/package/bcrypt#hash-info + //Why 60 in length? https://www.npmjs.com/package/bcrypt#hash-info password: { type: "varchar(60)", notNull: true, diff --git a/infra/scripts/wait-for-postgres.js b/infra/scripts/wait-for-postgres.js index 586d18e..2cb0a88 100644 --- a/infra/scripts/wait-for-postgres.js +++ b/infra/scripts/wait-for-postgres.js @@ -7,12 +7,12 @@ function checkPostgres() { return checkPostgres(); } loader.stopLoader(); - console.log("🟢 Postgres esta aceitando conexões.\n"); + console.log("🟢 Postgres is accepting connections..\n"); } exec("docker exec postgres-dev pg_isready --host localhost", handleReturn); } loader.startLoader({ - text: "🔴 Aguardando o postgres aceitar conexõe", + text: "🔴 Waiting for Postgres to accept connections...", }); checkPostgres(); diff --git a/models/password.js b/models/password.js index c1968c5..7a37bba 100644 --- a/models/password.js +++ b/models/password.js @@ -17,10 +17,10 @@ function getNumberOfRound() { function mixPepper(password) { const pepper = process.env.PEPPER; - const peperSize = Math.round(pepper.length / 2); + const pepperSize = Math.round(pepper.length / 2); - const frontPepper = pepper.slice(0, peperSize); - const backPepper = pepper.slice(peperSize, pepper.length); + const frontPepper = pepper.slice(0, pepperSize); + const backPepper = pepper.slice(pepperSize, pepper.length); return frontPepper + password + backPepper; } diff --git a/models/session.js b/models/session.js index 6db1d6d..c29cc60 100644 --- a/models/session.js +++ b/models/session.js @@ -2,10 +2,10 @@ import crypto from "node:crypto"; import database from "infra/database"; import { UnauthorizedError } from "infra/errors"; -const EXPIRATION_IN_MILISECONS = 60 * 60 * 24 * 30 * 1000; // 30 days +const EXPIRATION_IN_MILLISECONDS = 60 * 60 * 24 * 30 * 1000; // 30 days function getExpiresAt() { - return new Date(Date.now() + EXPIRATION_IN_MILISECONS); + return new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); } async function findOneValidByToken(sessionToken) { @@ -20,9 +20,9 @@ async function findOneValidByToken(sessionToken) { FROM sessions WHERE - token = $1 + token = $1 AND expires_at > NOW() - LIMIT + LIMIT 1 ;`, values: [sessionToken], @@ -67,8 +67,8 @@ async function renew(sessionId) { async function runUpdateQuery(sessionId, expiresAt) { const result = await database.query({ text: ` - UPDATE - sessions + UPDATE + sessions SET expires_at = $1, updated_at = NOW() @@ -83,15 +83,15 @@ async function renew(sessionId) { } } -async function exporeById(sessionId) { +async function expireById(sessionId) { const expiredSessionObject = await runUpdateQuery(sessionId); return expiredSessionObject; async function runUpdateQuery(sessionId) { const result = await database.query({ text: ` - UPDATE - sessions + UPDATE + sessions SET expires_at = expires_at - interval '1 year', updated_at = NOW() @@ -109,9 +109,9 @@ async function exporeById(sessionId) { const session = { create, findOneValidByToken, - EXPIRATION_IN_MILISECONS, + EXPIRATION_IN_MILLISECONDS, renew, - exporeById, + expireById, }; export default session; diff --git a/models/user.js b/models/user.js index 0db237c..35e4374 100644 --- a/models/user.js +++ b/models/user.js @@ -91,10 +91,10 @@ async function create(userInputValues) { await validateUniqueUsername(userInputValues.username); await hashPasswordInObject(userInputValues); - const newUser = await runInsertQuerry(userInputValues); + const newUser = await runInsertQuery(userInputValues); return newUser; - async function runInsertQuerry(userInputValues) { + async function runInsertQuery(userInputValues) { const result = await database.query({ text: ` INSERT INTO @@ -134,10 +134,10 @@ async function update(username, userInputValues) { ...userInputValues, }; - const updatedUser = await runUpdateQuerry(userWithNewValues); + const updatedUser = await runUpdateQuery(userWithNewValues); return updatedUser; - async function runUpdateQuerry(userInputValues) { + async function runUpdateQuery(userInputValues) { const result = await database.query({ text: ` UPDATE @@ -177,7 +177,7 @@ async function validateUniqueUsername(username) { }); if (result.rowCount > 0) { throw new ValidationError({ - message: "This username is not avalible", + message: "This username is not available", action: "Use another username to perform this operation", }); } @@ -197,7 +197,7 @@ async function validateUniqueEmail(email) { }); if (result.rowCount > 0) { throw new ValidationError({ - message: "This email is not avalible", + message: "This email is not available", action: "Use another email to perform this operation.", }); } diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 82bc3d8..5202d75 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -28,7 +28,7 @@ async function deleteHandler(request, response) { const sessionToken = request.cookies.session_id; const sessionObject = await session.findOneValidByToken(sessionToken); - const expiredSession = await session.exporeById(sessionObject.id); + const expiredSession = await session.expireById(sessionObject.id); controller.clearSessionCookie(response); return response.status(200).json(expiredSession); diff --git a/tests/integration/api/v1/migrations/delete.test.js b/tests/integration/api/v1/migrations/delete.test.js index 4308a5f..6c84ded 100644 --- a/tests/integration/api/v1/migrations/delete.test.js +++ b/tests/integration/api/v1/migrations/delete.test.js @@ -5,8 +5,8 @@ beforeAll(async () => { }); describe("DELETE /api/v1/status", () => { - describe("Anonymos user", () => { - test("Delete pedding migrations", async () => { + describe("Anonymous user", () => { + test("Delete pending migrations", async () => { const response = await fetch("http://localhost:3000/api/v1/migrations", { method: "DELETE", }); diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index 6e6ea72..c70d2bb 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -6,8 +6,8 @@ beforeAll(async () => { }); describe("GET /api/v1/migrations", () => { - describe("Anonymos user", () => { - test("Retrieving pedding migrations", async () => { + describe("Anonymous user", () => { + test("Retrieving pending migrations", async () => { const response = await fetch("http://localhost:3000/api/v1/migrations"); expect(response.status).toBe(200); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 6913b52..b404e75 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -6,8 +6,8 @@ beforeAll(async () => { }); describe("POST /api/v1/migrations", () => { - describe("Anonymos user", () => { - describe("Running pedding migrations", () => { + describe("Anonymous user", () => { + describe("Running pending migrations", () => { test("Running for first time", async () => { const response1 = await fetch( "http://localhost:3000/api/v1/migrations", diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index fc53abd..603797a 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -35,7 +35,7 @@ describe("DELETE /api/v1/user", () => { test("With expired session", async () => { jest.useFakeTimers({ - now: new Date(Date.now() - session.EXPIRATION_IN_MILISECONS), + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), }); const createdUser = await orchestrator.createUser({ @@ -74,10 +74,10 @@ describe("DELETE /api/v1/user", () => { }); const responseBody = await response.json(); - // const caheControl = response.headers.get("Cache-Control"); + // const cacheControl = response.headers.get("Cache-Control"); expect(response.status).toBe(200); - // expect(caheControl).toBe( + // expect(cacheControl).toBe( // "no-store, no-cache, max-age=0, must-revalidate", // ); expect(responseBody).toEqual({ diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index ac549e6..9965287 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -10,7 +10,7 @@ beforeAll(async () => { }); describe("POST /api/v1/sessions", () => { - describe("Anonymos user", () => { + describe("Anonymous user", () => { test("With incorrect `email` but correct `password`", async () => { await orchestrator.createUser({ password: "correct-password", @@ -128,14 +128,14 @@ describe("POST /api/v1/sessions", () => { expiresAt.setMilliseconds(0); createdAt.setMilliseconds(0); - expect(expiresAt - createdAt).toEqual(session.EXPIRATION_IN_MILISECONS); + expect(expiresAt - createdAt).toEqual(session.EXPIRATION_IN_MILLISECONDS); const parsedCookie = setCookieParser(response, { map: true }); expect(parsedCookie.session_id).toEqual({ name: "session_id", httpOnly: true, path: "/", - maxAge: session.EXPIRATION_IN_MILISECONS / 1000, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, value: responseBody.token, }); }); diff --git a/tests/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js index d1e1956..b05923a 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -5,7 +5,7 @@ beforeAll(async () => { }); describe("GET /api/v1/status", () => { - describe("Anonymos user", () => { + describe("Anonymous user", () => { test("Retrieving current system status", async () => { const response = await fetch("http://localhost:3000/api/v1/status"); expect(response.status).toBe(200); diff --git a/tests/integration/api/v1/status/post.test.js b/tests/integration/api/v1/status/post.test.js index 9f30ace..c6f3037 100644 --- a/tests/integration/api/v1/status/post.test.js +++ b/tests/integration/api/v1/status/post.test.js @@ -5,7 +5,7 @@ beforeAll(async () => { }); describe("POST /api/v1/status", () => { - describe("Anonymos user", () => { + describe("Anonymous user", () => { test("Create current system status", async () => { const response = await fetch("http://localhost:3000/api/v1/status", { method: "POST", diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index a578f73..316f670 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -24,10 +24,10 @@ describe("GET /api/v1/user", () => { }); const responseBody = await response.json(); - const caheControl = response.headers.get("Cache-Control"); + const cacheControl = response.headers.get("Cache-Control"); expect(response.status).toBe(200); - expect(caheControl).toBe( + expect(cacheControl).toBe( "no-store, no-cache, max-age=0, must-revalidate", ); expect(responseBody).toEqual({ @@ -59,7 +59,7 @@ describe("GET /api/v1/user", () => { const updatedAt = new Date(renewedSessionObject.updated_at); expiresAt.setMilliseconds(0); updatedAt.setMilliseconds(0); - expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILISECONS); + expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILLISECONDS); // Set-cookie header assertions const parsedCookie = setCookieParser(response, { map: true }); @@ -67,7 +67,7 @@ describe("GET /api/v1/user", () => { name: "session_id", httpOnly: true, path: "/", - maxAge: session.EXPIRATION_IN_MILISECONS / 1000, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, value: renewedSessionObject.token, }); }); @@ -105,7 +105,7 @@ describe("GET /api/v1/user", () => { test("With expired session", async () => { jest.useFakeTimers({ - now: new Date(Date.now() - session.EXPIRATION_IN_MILISECONS), + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS), }); const createdUser = await orchestrator.createUser({ @@ -144,7 +144,7 @@ describe("GET /api/v1/user", () => { test("With valid session about to expire", async () => { jest.useFakeTimers({ - now: new Date(Date.now() - session.EXPIRATION_IN_MILISECONS + 100), + now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS + 200), }); const createdUser = await orchestrator.createUser({ @@ -192,7 +192,7 @@ describe("GET /api/v1/user", () => { const updatedAt = new Date(renewedSessionObject.updated_at); expiresAt.setMilliseconds(0); updatedAt.setMilliseconds(0); - expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILISECONS); + expect(expiresAt - updatedAt).toEqual(session.EXPIRATION_IN_MILLISECONDS); // Set-cookie header assertions const parsedCookie = setCookieParser(response, { map: true }); @@ -200,7 +200,7 @@ describe("GET /api/v1/user", () => { name: "session_id", httpOnly: true, path: "/", - maxAge: session.EXPIRATION_IN_MILISECONS / 1000, + maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, value: renewedSessionObject.token, }); }); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 3916164..bd23723 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -8,14 +8,14 @@ beforeAll(async () => { }); describe("GET /api/v1/users/[username]", () => { - describe("Anonymos user", () => { + describe("Anonymous user", () => { test("With exact case match", async () => { const createdUser = await orchestrator.createUser({ - username: "MesmoCase", + username: "SameCase", }); const response = await fetch( - `http://localhost:3000/api/v1/users/MesmoCase`, + `http://localhost:3000/api/v1/users/SameCase`, ); const responseBody = await response.json(); @@ -23,7 +23,7 @@ describe("GET /api/v1/users/[username]", () => { expect(response.status).toBe(200); expect(responseBody).toEqual({ id: responseBody.id, - username: "MesmoCase", + username: "SameCase", email: createdUser.email, password: responseBody.password, created_at: responseBody.created_at, @@ -36,11 +36,11 @@ describe("GET /api/v1/users/[username]", () => { test("With case mismatch", async () => { const createdUser = await orchestrator.createUser({ - username: "CaseDiferente", + username: "DifferentCase", }); const response = await fetch( - `http://localhost:3000/api/v1/users/casediferente`, + `http://localhost:3000/api/v1/users/differentcase`, ); const responseBody = await response.json(); @@ -48,7 +48,7 @@ describe("GET /api/v1/users/[username]", () => { expect(response.status).toBe(200); expect(responseBody).toEqual({ id: responseBody.id, - username: "CaseDiferente", + username: "DifferentCase", email: createdUser.email, password: responseBody.password, created_at: responseBody.created_at, @@ -59,7 +59,7 @@ describe("GET /api/v1/users/[username]", () => { expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); }); - test("With noexistent 'username'", async () => { + test("With no existent 'username'", async () => { const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioQueNaoExiste", ); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 641e4c6..0189504 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -10,7 +10,7 @@ beforeAll(async () => { }); describe("PATCH /api/v1/users/[username]", () => { - describe("Anonymos user", () => { + describe("Anonymous user", () => { test("With case no match", async () => { const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioQueNaoExiste", @@ -57,7 +57,7 @@ describe("PATCH /api/v1/users/[username]", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ name: "ValidationError", - message: "This username is not avalible", + message: "This username is not available", action: "Use another username to perform this operation", status_code: 400, }); @@ -90,7 +90,7 @@ describe("PATCH /api/v1/users/[username]", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ name: "ValidationError", - message: "This email is not avalible", + message: "This email is not available", action: "Use another email to perform this operation.", status_code: 400, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 19e30c3..d92df29 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -10,8 +10,8 @@ beforeAll(async () => { }); describe("POST /api/v1/users", () => { - describe("Anonymos user", () => { - test("With unique and valide data", async () => { + describe("Anonymous user", () => { + test("With unique and valid data", async () => { const response = await fetch("http://localhost:3000/api/v1/users", { method: "POST", headers: { @@ -75,7 +75,7 @@ describe("POST /api/v1/users", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ name: "ValidationError", - message: "This email is not avalible", + message: "This email is not available", action: "Use another email to perform this operation.", status_code: 400, }); @@ -103,7 +103,7 @@ describe("POST /api/v1/users", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ name: "ValidationError", - message: "This username is not avalible", + message: "This username is not available", action: "Use another username to perform this operation", status_code: 400, }); From b92b3d7941fc3b383f5d314353db41fbf08b9290 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Fri, 16 Jan 2026 09:22:47 +0000 Subject: [PATCH 03/23] feat: add `features` column to `users` table --- .../migrations/1768554236308_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/1768554236308_add-features-to-users.js diff --git a/infra/migrations/1768554236308_add-features-to-users.js b/infra/migrations/1768554236308_add-features-to-users.js new file mode 100644 index 0000000..f2d73c4 --- /dev/null +++ b/infra/migrations/1768554236308_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 316f670..e186ab4 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -34,6 +34,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(), @@ -167,6 +168,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithAboutToExpireSession", 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 bd23723..a80bb1a 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -25,6 +25,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "SameCase", email: createdUser.email, + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -50,6 +51,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "DifferentCase", 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/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 0189504..ff0ac49 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -119,6 +119,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser", email: createdUser.email, + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -152,6 +153,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail@test.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -187,6 +189,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 d92df29..a756980 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,6 +31,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "isaacisrael", email: "isaac@test.com", + features: [], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, From b654a369b37b2df3d89e3473eadf896ecb204820 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Fri, 16 Jan 2026 11:43:44 +0000 Subject: [PATCH 04/23] feat: add default feature `read:activation_token` when creating `user` --- models/user.js | 30 +++++++----- .../_use-cases/registration-flow.test.js | 48 +++++++++++++++++++ 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, 74 insertions(+), 20 deletions(-) create mode 100644 tests/integration/_use-cases/registration-flow.test.js diff --git a/models/user.js b/models/user.js index 35e4374..8164f3e 100644 --- a/models/user.js +++ b/models/user.js @@ -11,11 +11,11 @@ async function findOneById(id) { text: ` SELECT * - FROM + FROM users WHERE id = $1 - LIMIT + LIMIT 1 `, values: [id], @@ -39,11 +39,11 @@ async function findOneByUsername(username) { text: ` SELECT * - FROM + FROM users WHERE LOWER(username) = LOWER($1) - LIMIT + LIMIT 1 `, values: [username], @@ -67,11 +67,11 @@ async function findOneByEmail(email) { text: ` SELECT * - FROM + FROM users WHERE LOWER(email) = LOWER($1) - LIMIT + LIMIT 1 `, values: [email], @@ -90,6 +90,7 @@ async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); await hashPasswordInObject(userInputValues); + injectDefaultFeaturesInObject(userInputValues); const newUser = await runInsertQuery(userInputValues); return newUser; @@ -97,10 +98,10 @@ async function create(userInputValues) { async function runInsertQuery(userInputValues) { const result = await database.query({ text: ` - INSERT INTO - users (username, email, password) - VALUES - ($1, $2, $3) + INSERT INTO + users (username, email, password, features) + VALUES + ($1, $2, $3, $4) RETURNING * `, @@ -108,10 +109,15 @@ async function create(userInputValues) { userInputValues.username, userInputValues.email, userInputValues.password, + userInputValues.features, ], }); return result.rows[0]; } + + function injectDefaultFeaturesInObject(userInputValues) { + userInputValues.features = ["read:activation_token"]; + } } async function update(username, userInputValues) { @@ -168,7 +174,7 @@ async function validateUniqueUsername(username) { text: ` SELECT username - FROM + FROM users WHERE LOWER(username) = LOWER($1) @@ -188,7 +194,7 @@ async function validateUniqueEmail(email) { text: ` SELECT email - FROM + FROM users WHERE LOWER(email) = LOWER($1) 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..fc46487 --- /dev/null +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -0,0 +1,48 @@ +import orchestrator from "tests/orchestrator"; + +beforeAll(async () => { + await orchestrator.waitAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); + await orchestrator.deleteAllEmails(); +}); + +describe("Use case: Registration Flow (all successful)", () => { + test("Create user account", async () => { + const createUserResponse = await fetch( + "http://localhost:3000/api/v1/users", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "RegistrationFlow", + email: "registration.flow@test.com", + password: "senha123", + }), + }, + ); + + expect(createUserResponse.status).toBe(201); + + const responseBody = await createUserResponse.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + username: "RegistrationFlow", + email: "registration.flow@test.com", + features: ["read:activation_token"], + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + }); + + test.todo("Receive activation email"); + + test.todo("Activate account"); + + test.todo("Login"); + + test.todo("Get user information"); +}); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index e186ab4..8d6f2b1 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -34,7 +34,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(), @@ -168,7 +168,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithAboutToExpireSession", 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 a80bb1a..58733ee 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -25,7 +25,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "SameCase", email: createdUser.email, - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -51,7 +51,7 @@ describe("GET /api/v1/users/[username]", () => { id: responseBody.id, username: "DifferentCase", 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/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index ff0ac49..13eed6a 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -119,7 +119,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser", email: createdUser.email, - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -153,7 +153,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail@test.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, @@ -189,7 +189,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 a756980..62c8493 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,7 +31,7 @@ describe("POST /api/v1/users", () => { id: responseBody.id, username: "isaacisrael", email: "isaac@test.com", - features: [], + features: ["read:activation_token"], password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, From 09a81bf07a2d4c75b3de0313dede0037e161a02c Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 16:36:50 +0000 Subject: [PATCH 05/23] feat: handle empty email list in `orchestrator.getLastEmail()` --- tests/orchestrator.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/orchestrator.js b/tests/orchestrator.js index 0c8ced7..b6bb903 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -70,6 +70,10 @@ async function getLastEmail() { const emailListResponseBody = await emailListResponse.json(); const lastEmailItem = emailListResponseBody.pop(); + if (!lastEmailItem) { + return null; + } + const emailTextResponse = await fetch( `${emailHttpUrl}/messages/${lastEmailItem.id}.plain`, ); From c937a48df35baa89821813106384e5a88045767d Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 16:41:36 +0000 Subject: [PATCH 06/23] feat: send activation email after `user` registration --- ...565094427_create-user-activation-tokens.js | 38 ++++++++++ infra/webserver.js | 29 ++++++++ models/activation.js | 72 +++++++++++++++++++ pages/api/v1/users/index.js | 5 ++ .../_use-cases/registration-flow.test.js | 27 +++++-- 5 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 infra/migrations/1768565094427_create-user-activation-tokens.js create mode 100644 infra/webserver.js create mode 100644 models/activation.js diff --git a/infra/migrations/1768565094427_create-user-activation-tokens.js b/infra/migrations/1768565094427_create-user-activation-tokens.js new file mode 100644 index 0000000..7b3bb4e --- /dev/null +++ b/infra/migrations/1768565094427_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..46f52c1 --- /dev/null +++ b/infra/webserver.js @@ -0,0 +1,29 @@ +function getOrigin() { + if ( + ["development", "test"].includes(process.env.NODE_ENV) && + !process.env.CODESPACES + ) { + return "http://localhost:3000"; + } + + if (process.env.CODESPACES === "true") { + const port = process.env.PORT; + const codespaceName = process.env.CODESPACE_NAME; + const forwardingDomain = + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN; + + return `https://${codespaceName}-${port}.${forwardingDomain}`; + } + + if (process.env.VERCEL_ENV === "preview") { + return `https://${process.env.VERCEL_URL}`; + } + + return "https://iisrael.com.br"; +} + +const webserver = { + origin: getOrigin(), +}; + +export default webserver; diff --git a/models/activation.js b/models/activation.js new file mode 100644 index 0000000..8dbd15c --- /dev/null +++ b/models/activation.js @@ -0,0 +1,72 @@ +import database from "infra/database"; +import email from "infra/email"; +import webserver from "infra/webserver"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes + +async function create(userId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + const newToken = await runInsertQuery(userId, expiresAt); + return newToken; + + async function runInsertQuery(userId, expiresAt) { + const result = await database.query({ + text: ` + INSERT INTO + user_activation_tokens (user_id, expires_at) + VALUES + ($1, $2) + RETURNING + * + `, + values: [userId, expiresAt], + }); + return result.rows[0]; + } +} + +async function findOneByUserId(userID) { + const newToken = await runSelectQuery(userID); + return newToken; + + async function runSelectQuery(userID) { + const result = await database.query({ + text: ` + SELECT + * + FROM + user_activation_tokens + WHERE + user_id = $1 + LIMIT + 1 + `, + values: [userID], + }); + + return result.rows[0]; + } +} + +async function sendEmailToUser(user, activationToken) { + await email.send({ + from: "InSystem ", + to: user.email, + subject: "Activate you account at InSystem", + text: `${user.username} welcome! +Please activate your account using the following link: + +${webserver.origin}/register/activate/${activationToken.id} + +Best regards, +The InSystem 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 f93e721..0b26ccb 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -1,6 +1,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import activation from "models/activation"; const router = createRouter(); @@ -11,5 +12,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 fc46487..be5bc32 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,3 +1,4 @@ +import activation from "models/activation"; import orchestrator from "tests/orchestrator"; beforeAll(async () => { @@ -8,6 +9,7 @@ beforeAll(async () => { }); describe("Use case: Registration Flow (all successful)", () => { + let createUserResponseBody; test("Create user account", async () => { const createUserResponse = await fetch( "http://localhost:3000/api/v1/users", @@ -26,19 +28,30 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createUserResponse.status).toBe(201); - const responseBody = await createUserResponse.json(); - expect(responseBody).toEqual({ - id: responseBody.id, + createUserResponseBody = await createUserResponse.json(); + expect(createUserResponseBody).toEqual({ + id: createUserResponseBody.id, username: "RegistrationFlow", email: "registration.flow@test.com", features: ["read:activation_token"], - password: responseBody.password, - created_at: responseBody.created_at, - updated_at: responseBody.updated_at, + password: createUserResponseBody.password, + created_at: createUserResponseBody.created_at, + updated_at: createUserResponseBody.updated_at, }); }); - test.todo("Receive activation email"); + 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 you account at InSystem"); + expect(lastEmail.text).toContain("RegistrationFlow"); + expect(lastEmail.text).toContain(activationToken.id); + }); test.todo("Activate account"); From 2aa8a94ba532f490540871d0d53a34b1a3de8a1b Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 18:37:36 +0000 Subject: [PATCH 07/23] feat: add `activation.findOneValidById()` and `orchestractor.extractUUID()` --- infra/webserver.js | 2 +- models/activation.js | 24 +++++++++++++------ .../_use-cases/registration-flow.test.js | 16 +++++++++---- tests/orchestrator.js | 8 +++++++ 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/infra/webserver.js b/infra/webserver.js index 46f52c1..2727939 100644 --- a/infra/webserver.js +++ b/infra/webserver.js @@ -7,7 +7,7 @@ function getOrigin() { } if (process.env.CODESPACES === "true") { - const port = process.env.PORT; + const port = process.env.PORT ?? "3000"; const codespaceName = process.env.CODESPACE_NAME; const forwardingDomain = process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN; diff --git a/models/activation.js b/models/activation.js index 8dbd15c..32ea857 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,5 +1,6 @@ import database from "infra/database"; import email from "infra/email"; +import { NotFoundError } from "infra/errors"; import webserver from "infra/webserver"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -25,11 +26,11 @@ async function create(userId) { } } -async function findOneByUserId(userID) { - const newToken = await runSelectQuery(userID); +async function findOneValidById(id) { + const newToken = await runSelectQuery(id); return newToken; - async function runSelectQuery(userID) { + async function runSelectQuery(id) { const result = await database.query({ text: ` SELECT @@ -37,13 +38,22 @@ async function findOneByUserId(userID) { FROM user_activation_tokens WHERE - user_id = $1 + id = $1 + AND expires_at > NOW() + AND used_at IS NULL LIMIT 1 `, - values: [userID], + values: [id], }); + if (result.rowCount === 0) { + throw new NotFoundError({ + message: "Activation token not found or is no longer valid.", + action: "Try registering again.", + }); + } + return result.rows[0]; } } @@ -56,7 +66,7 @@ async function sendEmailToUser(user, activationToken) { text: `${user.username} welcome! Please activate your account using the following link: -${webserver.origin}/register/activate/${activationToken.id} +${webserver.origin}/registration/activate/${activationToken.id} Best regards, The InSystem Team @@ -65,7 +75,7 @@ The InSystem 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 be5bc32..144f201 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -1,3 +1,4 @@ +import webserver from "infra/webserver"; import activation from "models/activation"; import orchestrator from "tests/orchestrator"; @@ -42,15 +43,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 you account at InSystem"); expect(lastEmail.text).toContain("RegistrationFlow"); - expect(lastEmail.text).toContain(activationToken.id); + + const activationToken = orchestrator.extractUUID(lastEmail.text); + + expect(lastEmail.text).toContain( + `${webserver.origin}/registration/activate/${activationToken}`, + ); + + const activationTokenObject = + await activation.findOneValidById(activationToken); + expect(activationTokenObject.user_id).toBe(createUserResponseBody.id); + expect(activationTokenObject.used_at).toBeNull(); }); test.todo("Activate account"); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index b6bb903..ad89be8 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -83,6 +83,13 @@ async function getLastEmail() { return lastEmailItem; } +function extractUUID(text) { + const match = text.match( + /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/, + ); + return match ? match[0] : null; +} + const orchestrator = { waitAllServices, clearDatabase, @@ -91,6 +98,7 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, }; export default orchestrator; From cb264dfbd3a33782b1e4317e5840f2f768573e14 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 19:11:24 +0000 Subject: [PATCH 08/23] feat: add `PATCH` `/api/v1/activations/[token_id]` --- models/activation.js | 41 +++++++++++++++++++ models/user.js | 24 +++++++++++ pages/api/v1/activations/[token_id]/index.js | 18 ++++++++ .../_use-cases/registration-flow.test.js | 24 ++++++++++- 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 pages/api/v1/activations/[token_id]/index.js diff --git a/models/activation.js b/models/activation.js index 32ea857..ab3359e 100644 --- a/models/activation.js +++ b/models/activation.js @@ -2,6 +2,7 @@ import database from "infra/database"; import email from "infra/email"; import { NotFoundError } from "infra/errors"; import webserver from "infra/webserver"; +import user from "./user"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -74,9 +75,49 @@ The InSystem Team }); } +async function markAsUsed(activationTokenId) { + const usedActivationToken = await runUpdateQuery(activationTokenId); + return usedActivationToken; + + async function runUpdateQuery(activationTokenId) { + const result = await database.query({ + text: ` + UPDATE + user_activation_tokens + SET + used_at = timezone('utc', now()), + updated_at = timezone('utc', now()) + WHERE + id = $1 + AND expires_at > NOW() + AND used_at IS NULL + RETURNING + * + `, + values: [activationTokenId], + }); + + if (result.rowCount === 0) { + throw new NotFoundError({ + message: "Activation token not found or is no longer valid.", + action: "Try registering again.", + }); + } + + return result.rows[0]; + } +} + +async function activateUserByUserId(userId) { + const activatedUser = user.setFeatures(userId, ["create:session"]); + return activatedUser; +} + const activation = { findOneValidById, create, sendEmailToUser, + markAsUsed, + activateUserByUserId, }; export default activation; diff --git a/models/user.js b/models/user.js index 8164f3e..2ff7a48 100644 --- a/models/user.js +++ b/models/user.js @@ -214,12 +214,36 @@ async function hashPasswordInObject(userInputValues) { userInputValues.password = hashPassword; } +async function setFeatures(userId, features) { + const updatedUser = await runUpdateQuery(userId, features); + return updatedUser; + + async function runUpdateQuery(userId, features) { + const result = await database.query({ + text: ` + UPDATE + users + SET + features = $2, + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + `, + values: [userId, features], + }); + return result.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..18d39f6 --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,18 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import activation from "models/activation"; + +const router = createRouter(); + +router.patch(patchHandler); + +export default router.handler(controller.errorHandlers); + +async function patchHandler(request, response) { + const activationTokenId = request.query.token_id; + + const usedActivationToken = await activation.markAsUsed(activationTokenId); + await activation.activateUserByUserId(usedActivationToken.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 144f201..0a0f862 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"; import activation from "models/activation"; +import user from "models/user"; import orchestrator from "tests/orchestrator"; beforeAll(async () => { @@ -11,6 +12,7 @@ beforeAll(async () => { describe("Use case: Registration Flow (all successful)", () => { let createUserResponseBody; + let activationToken; test("Create user account", async () => { const createUserResponse = await fetch( "http://localhost:3000/api/v1/users", @@ -49,7 +51,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(lastEmail.subject).toBe("Activate you account at InSystem"); expect(lastEmail.text).toContain("RegistrationFlow"); - const activationToken = orchestrator.extractUUID(lastEmail.text); + activationToken = orchestrator.extractUUID(lastEmail.text); expect(lastEmail.text).toContain( `${webserver.origin}/registration/activate/${activationToken}`, @@ -61,7 +63,25 @@ describe("Use case: Registration Flow (all successful)", () => { expect(activationTokenObject.used_at).toBeNull(); }); - test.todo("Activate account"); + test("Activate account", async () => { + const activationResponse = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken}`, + { + 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( + createUserResponseBody.username, + ); + expect(activatedUser.features).toEqual(["create:session"]); + }); test.todo("Login"); From 9b240207f7537a9ac66e1f24981aa9c7b4eec7d7 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Sat, 17 Jan 2026 20:13:24 +0000 Subject: [PATCH 09/23] feat: add `injectAnonymousOrUser` and `canRequest` middlewares to `/sessions` --- infra/controller.js | 55 ++++++++++++++++++- infra/errors.js | 20 +++++++ pages/api/v1/sessions/index.js | 4 +- .../_use-cases/registration-flow.test.js | 24 +++++++- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/infra/controller.js b/infra/controller.js index 11c0213..42c34f2 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,5 +1,6 @@ import * as cookie from "cookie"; import session from "models/session"; +import user from "models/user"; import { InternalServerError, @@ -7,6 +8,7 @@ import { NotFoundError, UnauthorizedError, ValidationError, + ForbiddenError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -15,7 +17,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); } @@ -51,6 +57,51 @@ async function clearSessionCookie(response) { response.setHeader("Set-Cookie", setCookie); } +async function injectAnonymousOrUser(request, response, next) { + let userObject; + if (request?.cookies.session_id) { + const sessionToken = request.cookies.session_id; + userObject = await getAuthenticatedUser(sessionToken); + } else { + userObject = await getAnonymousUser(); + } + + request.context = { + ...request.context, + user: userObject, + }; + + return next(); +} + +async function getAuthenticatedUser(sessionToken) { + const sessionObject = await session.findOneValidByToken(sessionToken); + const userObject = await user.findOneById(sessionObject.userId); + return userObject; +} + +async function getAnonymousUser() { + const anonymousUser = { + feature: ["read:activation_token", "create:session", "create:user"], + }; + return anonymousUser; +} + +function canRequest(feature) { + return async function canRequestMiddleware(request, response, next) { + const userTryingToRequest = request.context.user; + + if (userTryingToRequest.feature.includes(feature)) { + return next(); + } + + throw new ForbiddenError({ + message: "User do not have permission to perform this action.", + action: `Check user permissions has a feature ${feature}.`, + }); + }; +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, @@ -58,6 +109,8 @@ const controller = { }, setSessionCookie, clearSessionCookie, + injectAnonymousOrUser, + canRequest, }; export default controller; diff --git a/infra/errors.js b/infra/errors.js index 65dff0b..50cfbb9 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -116,3 +116,23 @@ export class UnauthorizedError extends Error { }; } } + +export class ForbiddenError extends Error { + constructor({ message, cause, action }) { + super(message || "Access denied", { + cause, + }); + this.name = "ForbiddenError"; + this.action = action || "Check user permissions before to continue."; + this.statusCode = 403; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 5202d75..01c960f 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -5,8 +5,8 @@ import authentication from "models/authentication"; import session from "models/session"; 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 0a0f862..ef1bae4 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -83,7 +83,29 @@ 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: createUserResponseBody.email, + password: "senha123", + }), + }, + ); + + expect(createSessionResponse.status).toBe(201); + + const createSessionResponseBody = await createSessionResponse.json(); + + expect(createSessionResponseBody.user_id).toEqual( + createUserResponseBody.id, + ); + }); test.todo("Get user information"); }); From 16088773959aa7509dc6b0aeae383538503818a3 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 08:35:27 +0000 Subject: [PATCH 10/23] feat: require `read:session` to access `/user` endpoint --- infra/controller.js | 8 ++-- models/activation.js | 5 ++- models/authorization.js | 14 +++++++ pages/api/v1/sessions/index.js | 12 ++++++ pages/api/v1/user/index.js | 3 +- .../_use-cases/registration-flow.test.js | 19 +++++++-- .../api/v1/sessions/delete.test.js | 1 - .../integration/api/v1/sessions/post.test.js | 2 +- tests/integration/api/v1/user/get.test.js | 39 ++++++++++++++----- tests/orchestrator.js | 12 ++++++ 10 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 models/authorization.js diff --git a/infra/controller.js b/infra/controller.js index 42c34f2..274828a 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,6 +1,7 @@ import * as cookie from "cookie"; import session from "models/session"; import user from "models/user"; +import authorization from "models/authorization"; import { InternalServerError, @@ -76,13 +77,13 @@ async function injectAnonymousOrUser(request, response, next) { async function getAuthenticatedUser(sessionToken) { const sessionObject = await session.findOneValidByToken(sessionToken); - const userObject = await user.findOneById(sessionObject.userId); + const userObject = await user.findOneById(sessionObject.user_id); return userObject; } async function getAnonymousUser() { const anonymousUser = { - feature: ["read:activation_token", "create:session", "create:user"], + features: ["read:activation_token", "create:session", "create:user"], }; return anonymousUser; } @@ -90,8 +91,7 @@ async function getAnonymousUser() { function canRequest(feature) { return async function canRequestMiddleware(request, response, next) { const userTryingToRequest = request.context.user; - - if (userTryingToRequest.feature.includes(feature)) { + if (authorization.can(userTryingToRequest, feature)) { return next(); } diff --git a/models/activation.js b/models/activation.js index ab3359e..8878f2a 100644 --- a/models/activation.js +++ b/models/activation.js @@ -109,7 +109,10 @@ async function markAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { - const activatedUser = user.setFeatures(userId, ["create:session"]); + const activatedUser = user.setFeatures(userId, [ + "create:session", + "read:session", + ]); return activatedUser; } diff --git a/models/authorization.js b/models/authorization.js new file mode 100644 index 0000000..47b5255 --- /dev/null +++ b/models/authorization.js @@ -0,0 +1,14 @@ +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 01c960f..6619ffa 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -2,8 +2,11 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import authentication from "models/authentication"; +import authorization from "models/authorization"; import session from "models/session"; +import { ForbiddenError } from "infra/errors"; + const router = createRouter(); router.use(controller.injectAnonymousOrUser); router.post(controller.canRequest("create:session"), postHandler); @@ -13,10 +16,19 @@ export default router.handler(controller.errorHandlers); async function postHandler(request, response) { const userInputValues = request.body; + const authenticatedUser = await authentication.getAuthenticatedUser( userInputValues.email, userInputValues.password, ); + + if (!authorization.can(authenticatedUser, "create:session")) { + throw new ForbiddenError({ + message: "User do not have permission to login.", + action: "Contact support for assistance if you believe this is an error.", + }); + } + const newSession = await session.create(authenticatedUser.id); controller.setSessionCookie(newSession.token, response); diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index d30dba1..3ef64de 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -5,7 +5,8 @@ import session from "models/session"; const router = createRouter(); -router.get(getHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:session"), getHandler); export default router.handler(controller.errorHandlers); diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index ef1bae4..d8bc511 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 activationToken; + let createSessionResponseBody; test("Create user account", async () => { const createUserResponse = await fetch( "http://localhost:3000/api/v1/users", @@ -80,7 +81,7 @@ describe("Use case: Registration Flow (all successful)", () => { const activatedUser = await user.findOneByUsername( createUserResponseBody.username, ); - expect(activatedUser.features).toEqual(["create:session"]); + expect(activatedUser.features).toEqual(["create:session", "read:session"]); }); test("Login", async () => { @@ -100,12 +101,24 @@ 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).toEqual( createUserResponseBody.id, ); }); - test.todo("Get user information"); + test("Get user information", async () => { + const response = await fetch(`http://localhost:3000/api/v1/user`, { + headers: { + Cookie: `session_id=${createSessionResponseBody.token}`, + }, + }); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody.id).toBe(createUserResponseBody.id); + }); }); diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index 603797a..b6dd218 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -72,7 +72,6 @@ describe("DELETE /api/v1/user", () => { Cookie: `session_id=${sessionObject.token}`, }, }); - const responseBody = await response.json(); // const cacheControl = response.headers.get("Cache-Control"); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index 9965287..12a003a 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -91,7 +91,7 @@ describe("POST /api/v1/sessions", () => { }); test("With correct `email` but correct `password`", async () => { - const createdUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createActivatedUser({ email: "all-correct.email@test.com", password: "all-correct-password", }); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 8d6f2b1..6ec13cf 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -11,9 +11,25 @@ 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: "User do not have permission to perform this action.", + action: "Check user permissions has a feature read:session.", + status_code: 403, + }); + }); + }); describe("Default user", () => { test("With valid session", async () => { - const createdUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createActivatedUser({ username: "UserWithValidSession", }); const sessionObject = await orchestrator.createSession(createdUser.id); @@ -23,10 +39,10 @@ describe("GET /api/v1/user", () => { }, }); + expect(response.status).toBe(200); + const responseBody = await response.json(); const cacheControl = response.headers.get("Cache-Control"); - - expect(response.status).toBe(200); expect(cacheControl).toBe( "no-store, no-cache, max-age=0, must-revalidate", ); @@ -34,7 +50,7 @@ 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(), @@ -83,8 +99,10 @@ describe("GET /api/v1/user", () => { }, }); - const responseBody = await response.json(); expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ name: "UnauthorizedError", message: "User do not have a valid session.", @@ -122,8 +140,10 @@ describe("GET /api/v1/user", () => { }, }); - const responseBody = await response.json(); expect(response.status).toBe(401); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ name: "UnauthorizedError", message: "User do not have a valid session.", @@ -148,7 +168,7 @@ describe("GET /api/v1/user", () => { now: new Date(Date.now() - session.EXPIRATION_IN_MILLISECONDS + 200), }); - const createdUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createActivatedUser({ username: "UserWithAboutToExpireSession", }); const sessionObject = await orchestrator.createSession(createdUser.id); @@ -161,14 +181,15 @@ describe("GET /api/v1/user", () => { }, }); + expect(response.status).toBe(200); + const responseBody = await response.json(); - expect(response.status).toBe(200); expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithAboutToExpireSession", 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(), diff --git a/tests/orchestrator.js b/tests/orchestrator.js index ad89be8..828d393 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -1,6 +1,7 @@ import { faker } from "@faker-js/faker/."; import retry from "async-retry"; import database from "infra/database"; +import activation from "models/activation"; import migrator from "models/migrator"; import session from "models/session"; import user from "models/user"; @@ -57,6 +58,15 @@ async function createUser(userObject) { }); } +async function activateUser(inactiveUser) { + return await activation.activateUserByUserId(inactiveUser.id); +} + +async function createActivatedUser(userObject) { + const inactiveUser = await createUser(userObject); + return await activateUser(inactiveUser); +} + async function createSession(userId) { return session.create(userId); } @@ -99,6 +109,8 @@ const orchestrator = { deleteAllEmails, getLastEmail, extractUUID, + activateUser, + createActivatedUser, }; export default orchestrator; From de7a5aabda4df1ee716cf38d06496a1acaf38da2 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 08:44:46 +0000 Subject: [PATCH 11/23] feat: require `read:activation_token` to access `/api/v1/activations/[token_id]` --- models/activation.js | 13 +- pages/api/v1/activations/[token_id]/index.js | 8 +- .../v1/activations/[token_id]/patch.test.js | 195 ++++++++++++++++++ 3 files changed, 213 insertions(+), 3 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 8878f2a..c2c0b4a 100644 --- a/models/activation.js +++ b/models/activation.js @@ -1,8 +1,9 @@ import database from "infra/database"; import email from "infra/email"; -import { NotFoundError } from "infra/errors"; +import { ForbiddenError, NotFoundError } from "infra/errors"; import webserver from "infra/webserver"; import user from "./user"; +import authorization from "./authorization"; const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes @@ -109,6 +110,15 @@ async function markAsUsed(activationTokenId) { } async function activateUserByUserId(userId) { + const userToActivated = await user.findOneById(userId); + + if (!authorization.can(userToActivated, "read:activation_token")) { + throw new ForbiddenError({ + message: "User not able anymore to use activation token.", + action: "Contact support for assistance.", + }); + } + const activatedUser = user.setFeatures(userId, [ "create:session", "read:session", @@ -122,5 +132,6 @@ const activation = { sendEmailToUser, markAsUsed, activateUserByUserId, + 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 18d39f6..f8e303c 100644 --- a/pages/api/v1/activations/[token_id]/index.js +++ b/pages/api/v1/activations/[token_id]/index.js @@ -4,15 +4,19 @@ import activation from "models/activation"; const router = createRouter(); -router.patch(patchHandler); +router.use(controller.injectAnonymousOrUser); +router.patch(controller.canRequest("read:activation_token"), patchHandler); export default router.handler(controller.errorHandlers); async function patchHandler(request, response) { const activationTokenId = request.query.token_id; + const validActivationToken = + await activation.findOneValidById(activationTokenId); + + await activation.activateUserByUserId(validActivationToken.user_id); const usedActivationToken = await activation.markAsUsed(activationTokenId); - await activation.activateUserByUserId(usedActivationToken.user_id); 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..85a7025 --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,195 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator"; +import activation from "models/activation"; +import user from "models/user"; + +beforeAll(async () => { + await orchestrator.waitAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH /api/v1/activations/[token_id]", () => { + describe("Anonymous user", () => { + test("With nonexisting token", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/activations/5c24ccd4-c5cb-40ee-9ca4-9df553976a4f", + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "Activation token not found or is no longer valid.", + action: "Try registering 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: "Activation token not found or is no longer valid.", + action: "Try registering 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 responseBody = await response2.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "Activation token not found or is no longer valid.", + action: "Try registering 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, + user_id: activationToken.user_id, + expires_at: activationToken.expires_at.toISOString(), + used_at: responseBody.used_at, + 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.updated_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + + const expiresAt = new Date(responseBody.expires_at); + const createdAt = new Date(responseBody.created_at); + expiresAt.setMilliseconds(0); + createdAt.setMilliseconds(0); + + expect(expiresAt - createdAt).toBe(activation.EXPIRATION_IN_MILLISECONDS); + + const activatedUser = await user.findOneById(responseBody.user_id); + expect(activatedUser.features).toEqual([ + "create:session", + "read:session", + ]); + }); + + test("With valid token but already activated user", async () => { + const createdUser = await orchestrator.createActivatedUser(); + 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: "User not able anymore to use activation token.", + action: "Contact support for assistance.", + status_code: 403, + }); + }); + }); + + describe("Default user", () => { + test("With valid token , but already logged in user", async () => { + const user1 = await orchestrator.createActivatedUser(); + const user1SessionObject = await orchestrator.createSession(user1.id); + + const user2 = await orchestrator.createUser(); + const user2ActivationToken = await activation.create(user2.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${user2ActivationToken.id}`, + { + method: "PATCH", + headers: { + Cookie: `session_id=${user1SessionObject.token}`, + }, + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "User do not have permission to perform this action.", + action: "Check user permissions has a feature read:activation_token.", + status_code: 403, + }); + }); + }); +}); From 4f7575208cd5e4cd1e5696143c8d6338f57b4508 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 15:48:55 +0000 Subject: [PATCH 12/23] feat: require `create:user` to access `api/v1/users` --- pages/api/v1/users/index.js | 4 +-- tests/integration/api/v1/users/post.test.js | 30 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index 0b26ccb..de1c857 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -4,8 +4,8 @@ import user from "models/user"; import activation from "models/activation"; const router = createRouter(); - -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:user"), postHandler); export default router.handler(controller.errorHandlers); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 62c8493..630a3e6 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -110,4 +110,34 @@ describe("POST /api/v1/users", () => { }); }); }); + + describe("Default user", () => { + test("With unique and valid data", async () => { + const user1 = await orchestrator.createActivatedUser(); + const user1SessionObject = await orchestrator.createSession(user1.id); + + const user2response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${user1SessionObject.token}`, + }, + body: JSON.stringify({ + username: "isaacisrael", + email: "isaac@test.com", + password: "senha123", + }), + }); + + expect(user2response.status).toBe(403); + + const responseBody = await user2response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + action: "Check user permissions has a feature create:user.", + message: "User do not have permission to perform this action.", + status_code: 403, + }); + }); + }); }); From 3d67baa3780d0e997c49ccf94d7b0dbdadcf1597 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 16:16:27 +0000 Subject: [PATCH 13/23] feat: require `update:user` to access `api/v1/users/[username]` --- models/activation.js | 1 + pages/api/v1/users/[username]/index.js | 3 +- .../_use-cases/registration-flow.test.js | 6 +- .../v1/activations/[token_id]/patch.test.js | 1 + tests/integration/api/v1/user/get.test.js | 4 +- .../api/v1/users/[username]/patch.test.js | 63 ++++++++++++++++--- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/models/activation.js b/models/activation.js index c2c0b4a..9472ad7 100644 --- a/models/activation.js +++ b/models/activation.js @@ -122,6 +122,7 @@ async function activateUserByUserId(userId) { const activatedUser = 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 7ea8e64..2d75001 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -4,8 +4,9 @@ import user from "models/user"; const router = createRouter(); +router.use(controller.injectAnonymousOrUser); router.get(getHandler); -router.patch(patchHandler); +router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index d8bc511..f20ce48 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -81,7 +81,11 @@ describe("Use case: Registration Flow (all successful)", () => { const activatedUser = await user.findOneByUsername( createUserResponseBody.username, ); - 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 85a7025..97597bc 100644 --- a/tests/integration/api/v1/activations/[token_id]/patch.test.js +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -135,6 +135,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 6ec13cf..e0b6047 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -50,7 +50,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: createdUser.updated_at.toISOString(), @@ -189,7 +189,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithAboutToExpireSession", 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: createdUser.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 13eed6a..3dd2c46 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -11,11 +11,46 @@ 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: "uniqueUser", + }), + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + action: "Check user permissions has a feature update:user.", + message: "User do not have permission to perform this action.", + status_code: 403, + }); + }); + }); + + describe("Default user", () => { test("With case no match", async () => { + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser.id); + const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioQueNaoExiste", { method: "PATCH", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, }, ); @@ -35,9 +70,8 @@ describe("PATCH /api/v1/users/[username]", () => { username: "user1", }); - const createdUser2 = await orchestrator.createUser({ - username: "user2", - }); + const createdUser2 = await orchestrator.createActivatedUser(); + const sessionObject2 = await orchestrator.createSession(createdUser2.id); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser2.username}`, @@ -45,6 +79,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ username: "user1", @@ -68,9 +103,10 @@ describe("PATCH /api/v1/users/[username]", () => { email: "email1@test.com", }); - const createdUser2 = await orchestrator.createUser({ + const createdUser2 = await orchestrator.createActivatedUser({ email: "email2@test.com", }); + const sessionObject2 = await orchestrator.createSession(createdUser2.id); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser2.username}`, @@ -78,6 +114,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ email: "email1@test.com", @@ -97,7 +134,8 @@ describe("PATCH /api/v1/users/[username]", () => { }); test("With unique 'username'", async () => { - const createdUser = await orchestrator.createUser(); + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser.id); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -105,6 +143,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser", @@ -119,7 +158,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: "uniqueUser", 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, @@ -131,7 +170,8 @@ describe("PATCH /api/v1/users/[username]", () => { }); test("With new 'email'", async () => { - const createdUser = await orchestrator.createUser(); + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser.id); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -139,6 +179,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ email: "uniqueEmail@test.com", @@ -153,7 +194,7 @@ describe("PATCH /api/v1/users/[username]", () => { id: responseBody.id, username: createdUser.username, email: "uniqueEmail@test.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, @@ -165,9 +206,10 @@ describe("PATCH /api/v1/users/[username]", () => { }); test("With new 'password'", async () => { - const createdUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createActivatedUser({ password: "oldPassword", }); + const sessionObject = await orchestrator.createSession(createdUser.id); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -175,6 +217,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword", @@ -189,7 +232,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 56e65cee5e6c51ab96c86fff1dceb7a25d11a096 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 16:36:30 +0000 Subject: [PATCH 14/23] feat: consider `resource` in `authorization` model --- models/authorization.js | 10 ++++++- pages/api/v1/users/[username]/index.js | 12 ++++++++ .../api/v1/users/[username]/patch.test.js | 30 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/models/authorization.js b/models/authorization.js index 47b5255..0332ffc 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -1,9 +1,17 @@ -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 2d75001..15ba189 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -1,6 +1,8 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import authorization from "models/authorization"; +import { ForbiddenError } from "infra/errors"; const router = createRouter(); @@ -21,6 +23,16 @@ async function patchHandler(request, response) { const username = request.query.username; const userInputValues = request.body; + const userTryingToPatch = request.context.user; + const targetUser = await user.findOneByUsername(username); + + if (!authorization.can(userTryingToPatch, "update:user", targetUser)) { + throw new ForbiddenError({ + message: "You don not have permission to update other user.", + action: "Check your permissions has a feature to update other users.", + }); + } + 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 3dd2c46..3f257c8 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -98,6 +98,36 @@ describe("PATCH /api/v1/users/[username]", () => { }); }); + test("With `user2` targeting `user1`", async () => { + const createsUser1 = await orchestrator.createUser(); + const createdUser2 = await orchestrator.createActivatedUser(); + const sessionObject2 = await orchestrator.createSession(createdUser2.id); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createsUser1.username}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, + }, + body: JSON.stringify({ + username: "user3", + }), + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + action: "Check your permissions has a feature to update other users.", + message: "You don not have permission to update other user.", + status_code: 403, + }); + }); + test("With duplicated 'email'", async () => { await orchestrator.createUser({ email: "email1@test.com", From 337c83cc26a82a27dcaae05b253b8ca640060251 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 19 Jan 2026 17:00:42 +0000 Subject: [PATCH 15/23] feat: allow `update:user:others` to update other users --- models/authorization.js | 2 +- models/user.js | 24 ++++++++++ .../api/v1/users/[username]/patch.test.js | 45 +++++++++++++++++++ tests/orchestrator.js | 6 +++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/models/authorization.js b/models/authorization.js index 0332ffc..5675b44 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -7,7 +7,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 2ff7a48..702d631 100644 --- a/models/user.js +++ b/models/user.js @@ -237,6 +237,29 @@ async function setFeatures(userId, features) { } } +async function addFeatures(userId, features) { + const updatedUser = await runUpdateQuery(userId, features); + return updatedUser; + + async function runUpdateQuery(userId, features) { + const result = 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 result.rows[0]; + } +} + const user = { create, findOneById, @@ -244,6 +267,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 3f257c8..78a1ca9 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -286,4 +286,49 @@ describe("PATCH /api/v1/users/[username]", () => { expect(incorrectPasswordMatch).toBe(false); }); }); + + describe("Privileged user", () => { + test("With `update:user:others` targeting `defaultUser`", async () => { + const defaultUser = await orchestrator.createActivatedUser(); + const privilegedUser = await orchestrator.createActivatedUser(); + await orchestrator.addFeaturesToUser(privilegedUser, [ + "update:user:others", + ]); + + const privilegedSession = await orchestrator.createSession( + privilegedUser.id, + ); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${defaultUser.username}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${privilegedSession.token}`, + }, + body: JSON.stringify({ + username: "user3", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + username: "user3", + email: defaultUser.email, + features: defaultUser.features, + password: responseBody.password, + created_at: defaultUser.created_at.toISOString(), + 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 828d393..bb8a085 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -71,6 +71,11 @@ async function createSession(userId) { return session.create(userId); } +async function addFeaturesToUser(userObject, features) { + const updatedUser = await user.addFeatures(userObject.id, features); + return updatedUser; +} + async function deleteAllEmails() { await fetch(`${emailHttpUrl}/messages`, { method: "DELETE" }); } @@ -111,6 +116,7 @@ const orchestrator = { extractUUID, activateUser, createActivatedUser, + addFeaturesToUser, }; export default orchestrator; From b43fbc3ef45171d0fdba31f8ff11aa3d1bc0f7cc Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Tue, 20 Jan 2026 08:22:16 +0000 Subject: [PATCH 16/23] refactor: update `createSession` to accept `user` object instead of `user_id` --- .../v1/activations/[token_id]/patch.test.js | 2 +- .../api/v1/sessions/delete.test.js | 4 ++-- tests/integration/api/v1/user/get.test.js | 6 +++--- .../api/v1/users/[username]/patch.test.js | 19 +++++++++---------- tests/integration/api/v1/users/post.test.js | 2 +- tests/orchestrator.js | 4 ++-- 6 files changed, 18 insertions(+), 19 deletions(-) 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 97597bc..89375ec 100644 --- a/tests/integration/api/v1/activations/[token_id]/patch.test.js +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -166,7 +166,7 @@ describe("PATCH /api/v1/activations/[token_id]", () => { describe("Default user", () => { test("With valid token , but already logged in user", async () => { const user1 = await orchestrator.createActivatedUser(); - const user1SessionObject = await orchestrator.createSession(user1.id); + const user1SessionObject = await orchestrator.createSession(user1); const user2 = await orchestrator.createUser(); const user2ActivationToken = await activation.create(user2.id); diff --git a/tests/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js index b6dd218..2733034 100644 --- a/tests/integration/api/v1/sessions/delete.test.js +++ b/tests/integration/api/v1/sessions/delete.test.js @@ -41,7 +41,7 @@ describe("DELETE /api/v1/user", () => { const createdUser = await orchestrator.createUser({ username: "UserWithExpiredSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); jest.useRealTimers(); const response = await fetch(`http://localhost:3000/api/v1/sessions`, { @@ -65,7 +65,7 @@ describe("DELETE /api/v1/user", () => { const createdUser = await orchestrator.createUser({ username: "UserWithValidSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch(`http://localhost:3000/api/v1/sessions`, { method: "DELETE", headers: { diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index e0b6047..3aeec83 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -32,7 +32,7 @@ describe("GET /api/v1/user", () => { const createdUser = await orchestrator.createActivatedUser({ username: "UserWithValidSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch(`http://localhost:3000/api/v1/user`, { headers: { Cookie: `session_id=${sessionObject.token}`, @@ -130,7 +130,7 @@ describe("GET /api/v1/user", () => { const createdUser = await orchestrator.createUser({ username: "UserWithExpiredSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); jest.useRealTimers(); @@ -171,7 +171,7 @@ describe("GET /api/v1/user", () => { const createdUser = await orchestrator.createActivatedUser({ username: "UserWithAboutToExpireSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); jest.useRealTimers(); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 78a1ca9..3071b6e 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -42,7 +42,7 @@ describe("PATCH /api/v1/users/[username]", () => { describe("Default user", () => { test("With case no match", async () => { const createdUser = await orchestrator.createActivatedUser(); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioQueNaoExiste", @@ -71,7 +71,7 @@ describe("PATCH /api/v1/users/[username]", () => { }); const createdUser2 = await orchestrator.createActivatedUser(); - const sessionObject2 = await orchestrator.createSession(createdUser2.id); + const sessionObject2 = await orchestrator.createSession(createdUser2); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser2.username}`, @@ -101,7 +101,7 @@ describe("PATCH /api/v1/users/[username]", () => { test("With `user2` targeting `user1`", async () => { const createsUser1 = await orchestrator.createUser(); const createdUser2 = await orchestrator.createActivatedUser(); - const sessionObject2 = await orchestrator.createSession(createdUser2.id); + const sessionObject2 = await orchestrator.createSession(createdUser2); const response = await fetch( `http://localhost:3000/api/v1/users/${createsUser1.username}`, @@ -136,7 +136,7 @@ describe("PATCH /api/v1/users/[username]", () => { const createdUser2 = await orchestrator.createActivatedUser({ email: "email2@test.com", }); - const sessionObject2 = await orchestrator.createSession(createdUser2.id); + const sessionObject2 = await orchestrator.createSession(createdUser2); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser2.username}`, @@ -165,7 +165,7 @@ describe("PATCH /api/v1/users/[username]", () => { test("With unique 'username'", async () => { const createdUser = await orchestrator.createActivatedUser(); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -201,7 +201,7 @@ describe("PATCH /api/v1/users/[username]", () => { test("With new 'email'", async () => { const createdUser = await orchestrator.createActivatedUser(); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -239,7 +239,7 @@ describe("PATCH /api/v1/users/[username]", () => { const createdUser = await orchestrator.createActivatedUser({ password: "oldPassword", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -295,9 +295,8 @@ describe("PATCH /api/v1/users/[username]", () => { "update:user:others", ]); - const privilegedSession = await orchestrator.createSession( - privilegedUser.id, - ); + const privilegedSession = + await orchestrator.createSession(privilegedUser); const response = await fetch( `http://localhost:3000/api/v1/users/${defaultUser.username}`, diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 630a3e6..9bbb9bd 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -114,7 +114,7 @@ describe("POST /api/v1/users", () => { describe("Default user", () => { test("With unique and valid data", async () => { const user1 = await orchestrator.createActivatedUser(); - const user1SessionObject = await orchestrator.createSession(user1.id); + const user1SessionObject = await orchestrator.createSession(user1); const user2response = await fetch("http://localhost:3000/api/v1/users", { method: "POST", diff --git a/tests/orchestrator.js b/tests/orchestrator.js index bb8a085..2ee81ff 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -67,8 +67,8 @@ async function createActivatedUser(userObject) { return await activateUser(inactiveUser); } -async function createSession(userId) { - return session.create(userId); +async function createSession(userObject) { + return session.create(userObject.id); } async function addFeaturesToUser(userObject, features) { From 4ea21b72dc7baeb3253be5209aac62f206f633b8 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Tue, 3 Feb 2026 08:42:27 +0000 Subject: [PATCH 17/23] feat: apply `authorization.filterOutput()` to all endpoints --- models/authorization.js | 73 ++++++++++++++++++ pages/api/v1/activations/[token_id]/index.js | 10 ++- pages/api/v1/migrations/index.js | 28 +++++-- pages/api/v1/sessions/index.js | 17 ++++- pages/api/v1/status/index.js | 16 +++- pages/api/v1/user/index.js | 10 ++- pages/api/v1/users/[username]/index.js | 19 ++++- pages/api/v1/users/index.js | 11 ++- .../_use-cases/registration-flow.test.js | 4 +- .../integration/api/v1/migrations/get.test.js | 46 ++++++++++- .../api/v1/migrations/post.test.js | 76 +++++++++++++------ tests/integration/api/v1/status/get.test.js | 59 ++++++++++++++ tests/integration/api/v1/user/get.test.js | 6 +- .../api/v1/users/[username]/get.test.js | 26 +++---- .../api/v1/users/[username]/patch.test.js | 42 +++++----- tests/integration/api/v1/users/post.test.js | 2 - 16 files changed, 358 insertions(+), 87 deletions(-) diff --git a/models/authorization.js b/models/authorization.js index 5675b44..a1d13cd 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -15,8 +15,81 @@ 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" && 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" && user.id === resource.user_id) { + return { + id: resource.id, + token: resource.token, + user_id: resource.user_id, + expires_at: resource.expires_at, + created_at: resource.created_at, + updated_at: resource.updated_at, + }; + } + + if (feature === "read:activation_token") { + return { + id: resource.id, + user_id: resource.user_id, + used_at: resource.used_at, + expires_at: resource.expires_at, + created_at: resource.created_at, + updated_at: resource.updated_at, + }; + } + + if (feature === "read:migration") { + return resource.map((migration) => ({ + 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 f8e303c..2cc731c 100644 --- a/pages/api/v1/activations/[token_id]/index.js +++ b/pages/api/v1/activations/[token_id]/index.js @@ -1,6 +1,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import activation from "models/activation"; +import authorization from "models/authorization"; const router = createRouter(); @@ -11,6 +12,7 @@ export default router.handler(controller.errorHandlers); async function patchHandler(request, response) { const activationTokenId = request.query.token_id; + const userTryingToPatch = request.context.user; const validActivationToken = await activation.findOneValidById(activationTokenId); @@ -18,5 +20,11 @@ async function patchHandler(request, response) { const usedActivationToken = await activation.markAsUsed(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 55c1e5a..d30a694 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -1,24 +1,42 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import migrator from "models/migrator"; +import authorization from "models/authorization"; const router = createRouter(); -router.get(getHandler); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:migration"), getHandler); +router.post(controller.canRequest("create:migration"), postHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const pendingMigrations = await migrator.listPendingMigrations(); - return response.status(200).json(pendingMigrations); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:migration", + pendingMigrations, + ); + + return response.status(200).json(secureOutputValues); } async function postHandler(request, response) { + const userTryingToPost = request.context.user; const migratedMigrations = await migrator.runPendingMigrations(); + + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:migration", + migratedMigrations, + ); + if (migratedMigrations.length > 0) { - return response.status(201).json(migratedMigrations); + return response.status(201).json(secureOutputValues); } - return response.status(200).json(migratedMigrations); + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 6619ffa..87181f8 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -33,15 +33,28 @@ async function postHandler(request, response) { controller.setSessionCookie(newSession.token, response); - return response.status(201).json(newSession); + const secureOutputValues = authorization.filterOutput( + authenticatedUser, + "read:session", + newSession, + ); + + return response.status(201).json(secureOutputValues); } async function deleteHandler(request, response) { const sessionToken = request.cookies.session_id; + const userTryingToDelete = request.context.user; const sessionObject = await session.findOneValidByToken(sessionToken); const expiredSession = await session.expireById(sessionObject.id); controller.clearSessionCookie(response); - return response.status(200).json(expiredSession); + const secureOutputValues = authorization.filterOutput( + 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 ae1172d..31fdd73 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -1,14 +1,16 @@ import { createRouter } from "next-connect"; import database from "infra/database.js"; import controller from "infra/controller"; +import authorization from "models/authorization"; const router = createRouter(); - +router.use(controller.injectAnonymousOrUser); router.get(getHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const updateAt = new Date().toISOString(); const dataBaseVersionResult = await database.query("SHOW server_version;"); @@ -28,7 +30,7 @@ async function getHandler(request, response) { const databaseOpenedConnectionsValue = databaseOpenedConnectionsResult.rows[0].count; - response.status(200).json({ + const statusObject = { updated_at: updateAt, dependencies: { database: { @@ -37,5 +39,13 @@ async function getHandler(request, response) { opened_connections: databaseOpenedConnectionsValue, }, }, - }); + }; + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:status", + statusObject, + ); // No sensitive data to filter here + + response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index 3ef64de..31a2d65 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -2,6 +2,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; import session from "models/session"; +import authorization from "models/authorization"; const router = createRouter(); @@ -12,6 +13,7 @@ export default router.handler(controller.errorHandlers); async function getHandler(request, response) { const sessionToken = request.cookies.session_id; + const userTryingToGet = request.context.user; const sessionObject = await session.findOneValidByToken(sessionToken); const renewedSessionObject = await session.renew(sessionObject.id); @@ -19,9 +21,15 @@ async function getHandler(request, response) { const userFound = await user.findOneById(sessionObject.user_id); + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user:self", + userFound, + ); + response.setHeader( "Cache-Control", "no-store, no-cache, max-age=0, must-revalidate", ); - return response.status(200).json(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 15ba189..e146eb2 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -13,10 +13,17 @@ router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const username = request.query.username; - const userFound = await user.findOneByUsername(username); - return response.status(200).json(userFound); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user", + userFound, + ); + + return response.status(200).json(secureOutputValues); } async function patchHandler(request, response) { @@ -35,5 +42,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 de1c857..39a2c95 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -2,6 +2,7 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; import activation from "models/activation"; +import authorization from "models/authorization"; const router = createRouter(); router.use(controller.injectAnonymousOrUser); @@ -11,10 +12,18 @@ export default router.handler(controller.errorHandlers); async function postHandler(request, response) { const userInputValues = request.body; + const userTryingToPost = request.context.user; + 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 f20ce48..0ab4fba 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -36,9 +36,7 @@ describe("Use case: Registration Flow (all successful)", () => { expect(createUserResponseBody).toEqual({ id: createUserResponseBody.id, username: "RegistrationFlow", - email: "registration.flow@test.com", features: ["read:activation_token"], - password: createUserResponseBody.password, created_at: createUserResponseBody.created_at, updated_at: createUserResponseBody.updated_at, }); @@ -97,7 +95,7 @@ describe("Use case: Registration Flow (all successful)", () => { "Content-Type": "application/json", }, body: JSON.stringify({ - email: createUserResponseBody.email, + email: "registration.flow@test.com", password: "senha123", }), }, diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index c70d2bb..b640f66 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -3,18 +3,62 @@ import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("GET /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { const response = await fetch("http://localhost:3000/api/v1/migrations"); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "User do not have permission to perform this action.", + action: "Check user permissions has a feature read:migration.", + status_code: 403, + }); + }); + }); + describe("Default user", () => { + test("Retrieving pending migrations", async () => { + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser); + 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: "User do not have permission to perform this action.", + action: "Check user permissions has a feature read:migration.", + status_code: 403, + }); + }); + }); + describe("Privileged user", () => { + test("With `read:migration` permission", async () => { + const createdUser = await orchestrator.createActivatedUser(); + await orchestrator.addFeaturesToUser(createdUser, ["read:migration"]); + const sessionObject = await orchestrator.createSession(createdUser); + 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 b404e75..b4a9a98 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,40 +3,66 @@ import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("POST /api/v1/migrations", () => { describe("Anonymous user", () => { - describe("Running pending migrations", () => { - test("Running for first time", async () => { - const response1 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); + test("Running pending migrations", async () => { + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + }); + + expect(response.status).toBe(403); - expect(response1.status).toBe(201); + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "User do not have permission to perform this action.", + action: "Check user permissions has a feature create:migration.", + status_code: 403, + }); + }); + }); + describe("Default user", () => { + test("Running pending migrations", async () => { + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser); + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); - const response1Body = await response1.json(); + expect(response.status).toBe(403); - expect(Array.isArray(response1Body)).toBe(true); - expect(response1Body.length).toBeGreaterThan(0); + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "User do not have permission to perform this action.", + action: "Check user permissions has a feature create:migration.", + status_code: 403, }); - test("Running for 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); + }); + }); + describe("Privileged user", () => { + test("With `create:migration` permission", async () => { + const createdUser = await orchestrator.createActivatedUser(); + await orchestrator.addFeaturesToUser(createdUser, ["create:migration"]); + const sessionObject = await orchestrator.createSession(createdUser); + 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 b05923a..056bbb9 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -16,6 +16,65 @@ describe("GET /api/v1/status", () => { const parseUpdateAt = new Date(responseBody.updated_at).toISOString(); expect(responseBody.updated_at).toBe(parseUpdateAt); + expect(responseBody.dependencies).toBeDefined(); + expect(responseBody.dependencies.database).toBeDefined(); + expect(responseBody.dependencies.database.max_connections).toBeDefined(); + expect(responseBody.dependencies.database.max_connections).toBe(100); + expect( + responseBody.dependencies.database.opened_connections, + ).toBeDefined(); + expect(responseBody.dependencies.database.opened_connections).toBe(1); + expect(responseBody.dependencies.database).not.toHaveProperty("version"); + }); + }); + + describe("Default user", () => { + test("Retrieving current system status", async () => { + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser); + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.updated_at).toBeDefined(); + + const parseUpdateAt = new Date(responseBody.updated_at).toISOString(); + expect(responseBody.updated_at).toBe(parseUpdateAt); + + expect(responseBody.dependencies).toBeDefined(); + expect(responseBody.dependencies.database).toBeDefined(); + expect(responseBody.dependencies.database.max_connections).toBeDefined(); + expect(responseBody.dependencies.database.max_connections).toBe(100); + expect( + responseBody.dependencies.database.opened_connections, + ).toBeDefined(); + expect(responseBody.dependencies.database.opened_connections).toBe(1); + expect(responseBody.dependencies.database).not.toHaveProperty("version"); + }); + }); + + describe("Privileged user", () => { + test("With `read:status:all` permission", async () => { + const createdUser = await orchestrator.createActivatedUser(); + await orchestrator.addFeaturesToUser(createdUser, ["read:status:all"]); + const sessionObject = await orchestrator.createSession(createdUser); + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody.updated_at).toBeDefined(); + + const parseUpdateAt = new Date(responseBody.updated_at).toISOString(); + expect(responseBody.updated_at).toBe(parseUpdateAt); + expect(responseBody.dependencies).toBeDefined(); expect(responseBody.dependencies.database).toBeDefined(); expect(responseBody.dependencies.database.version).toBeDefined(); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 3aeec83..040e054 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -50,8 +50,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithValidSession", email: createdUser.email, - features: ["create:session", "read:session", "update:user"], - password: createdUser.password, + features: createdUser.features, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), }); @@ -189,8 +188,7 @@ describe("GET /api/v1/user", () => { id: createdUser.id, username: "UserWithAboutToExpireSession", email: createdUser.email, - features: ["create:session", "read:session", "update:user"], - password: createdUser.password, + features: createdUser.features, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), }); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 58733ee..da79292 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -15,20 +15,18 @@ describe("GET /api/v1/users/[username]", () => { }); const response = await fetch( - `http://localhost:3000/api/v1/users/SameCase`, + `http://localhost:3000/api/v1/users/${createdUser.username}`, ); const responseBody = await response.json(); expect(response.status).toBe(200); expect(responseBody).toEqual({ - id: responseBody.id, - username: "SameCase", - email: createdUser.email, - features: ["read:activation_token"], - password: responseBody.password, - created_at: responseBody.created_at, - updated_at: responseBody.updated_at, + id: createdUser.id, + username: createdUser.username, + features: createdUser.features, + created_at: createdUser.created_at.toISOString(), + updated_at: createdUser.updated_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toBe(4); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); @@ -48,13 +46,11 @@ describe("GET /api/v1/users/[username]", () => { expect(response.status).toBe(200); expect(responseBody).toEqual({ - id: responseBody.id, - username: "DifferentCase", - email: createdUser.email, - features: ["read:activation_token"], - password: responseBody.password, - created_at: responseBody.created_at, - updated_at: responseBody.updated_at, + id: createdUser.id, + username: createdUser.username, + features: createdUser.features, + created_at: createdUser.created_at.toISOString(), + updated_at: createdUser.updated_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toBe(4); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index 3071b6e..18a2bef 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -185,12 +185,10 @@ describe("PATCH /api/v1/users/[username]", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - id: responseBody.id, + id: createdUser.id, username: "uniqueUser", - email: createdUser.email, - features: ["create:session", "read:session", "update:user"], - password: responseBody.password, - created_at: responseBody.created_at, + features: createdUser.features, + created_at: createdUser.created_at.toISOString(), updated_at: responseBody.updated_at, }); expect(uuidVersion(responseBody.id)).toBe(4); @@ -221,18 +219,21 @@ describe("PATCH /api/v1/users/[username]", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - id: responseBody.id, + id: createdUser.id, username: createdUser.username, - email: "uniqueEmail@test.com", - features: ["create:session", "read:session", "update:user"], - password: responseBody.password, - created_at: responseBody.created_at, + features: createdUser.features, + created_at: createdUser.created_at.toISOString(), 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); + + const userInDatabase = await user.findOneByUsername( + responseBody.username, + ); + expect(userInDatabase.email).toBe("uniqueEmail@test.com"); }); test("With new 'password'", async () => { @@ -259,12 +260,10 @@ describe("PATCH /api/v1/users/[username]", () => { const responseBody = await response.json(); expect(responseBody).toEqual({ - id: responseBody.id, + id: createdUser.id, username: createdUser.username, - email: createdUser.email, - features: ["create:session", "read:session", "update:user"], - password: responseBody.password, - created_at: responseBody.created_at, + features: createdUser.features, + created_at: createdUser.created_at.toISOString(), updated_at: responseBody.updated_at, }); expect(uuidVersion(responseBody.id)).toBe(4); @@ -272,16 +271,18 @@ describe("PATCH /api/v1/users/[username]", () => { expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); expect(responseBody.updated_at > responseBody.created_at).toBe(true); - const userInDatBase = await user.findOneByUsername(responseBody.username); + const userInDatabase = await user.findOneByUsername( + responseBody.username, + ); const correctPasswordMatch = await password.compare( "newPassword", - userInDatBase.password, + userInDatabase.password, ); expect(correctPasswordMatch).toBe(true); const incorrectPasswordMatch = await password.compare( "oldPassword", - userInDatBase.password, + userInDatabase.password, ); expect(incorrectPasswordMatch).toBe(false); }); @@ -315,12 +316,11 @@ describe("PATCH /api/v1/users/[username]", () => { expect(response.status).toBe(200); const responseBody = await response.json(); + expect(responseBody).toEqual({ - id: responseBody.id, + id: defaultUser.id, username: "user3", - email: defaultUser.email, features: defaultUser.features, - password: responseBody.password, created_at: defaultUser.created_at.toISOString(), 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 9bbb9bd..08ffa31 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -30,9 +30,7 @@ describe("POST /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "isaacisrael", - email: "isaac@test.com", features: ["read:activation_token"], - password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); From 8860458a79ed72ecfc44a9a86cbdfab6a2ca116d Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 9 Feb 2026 08:30:51 +0000 Subject: [PATCH 18/23] feat: validate `user`, `feature` and `resource` in `authorization` model --- models/authorization.js | 57 +++++++++++++++ tests/unit/models/authorization.test.js | 92 +++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/unit/models/authorization.test.js diff --git a/models/authorization.js b/models/authorization.js index a1d13cd..ee7946f 100644 --- a/models/authorization.js +++ b/models/authorization.js @@ -1,4 +1,33 @@ +import { InternalServerError } from "infra/errors"; + +const availableFeatures = [ + // User features + "read:user", + "create:user", + "read:user:self", + "update:user", + "update:user:others", + + // Session features + "read:session", + "create:session", + + // Activation token features + "read:activation_token", + + // Migration features + "read:migration", + "create:migration", + + // Status features + "read:status", + "read:status:all", +]; + function can(user, feature, resource) { + validateUser(user); + validateFeature(feature); + let authorized = false; if (user.features.includes(feature)) { @@ -16,6 +45,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, @@ -87,6 +120,30 @@ function filterOutput(user, feature, resource) { } } +function validateUser(user) { + if (!user || !user.features) { + throw new InternalServerError({ + cause: "`user` is required to model `authorization.js`.", + }); + } +} + +function validateFeature(feature) { + if (!feature || !availableFeatures.includes(feature)) { + throw new InternalServerError({ + cause: "known `feature` is required to model `authorization.js`.", + }); + } +} + +function validateResource(resource) { + if (!resource) { + throw new InternalServerError({ + cause: "`resource` is required to model `authorization.js`.", + }); + } +} + 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..4566538 --- /dev/null +++ b/tests/unit/models/authorization.test.js @@ -0,0 +1,92 @@ +import { InternalServerError } from "infra/errors"; +import authorization from "models/authorization"; + +describe("models/authorization", () => { + describe(".can()", () => { + test("Without `user`", () => { + expect(() => authorization.can()).toThrow(InternalServerError); + }); + + test("Without `user.features`", () => { + const createdUser = {}; + expect(() => authorization.can(createdUser)).toThrow(InternalServerError); + }); + + test("With valid `user` and 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 = {}; + expect(() => authorization.filterOutput(createdUser)).toThrow( + InternalServerError, + ); + }); + + test("With valid `user` and unknown `feature`", () => { + const createdUser = { + features: [], + }; + expect(() => + authorization.filterOutput(createdUser, "unknown:feature"), + ).toThrow(InternalServerError); + }); + + test("With valid `user` and known `feature` but no `resource`", () => { + const createdUser = { + features: [], + }; + expect(() => + authorization.filterOutput(createdUser, "unknown:feature"), + ).toThrow(InternalServerError); + }); + + test("with valid `user`, and known `feature` and `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + const resource = { + id: 1, + username: "resource", + features: ["read:user"], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + email: "resource@example.com", + password: "resource", + }; + + const results = authorization.filterOutput( + createdUser, + "read:user", + resource, + ); + + expect(results).toEqual({ + id: resource.id, + username: resource.username, + features: resource.features, + created_at: resource.created_at, + updated_at: resource.updated_at, + }); + }); + }); +}); From d87e8e098605ebfed6fadba3152d33eccb785e25 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 16 Feb 2026 08:38:59 +0000 Subject: [PATCH 19/23] chore: update `Node.js` version to `24` --- .npmrc | 3 +- .nvmrc | 2 +- package-lock.json | 110 ++-------------------------------------------- package.json | 3 ++ 4 files changed, 9 insertions(+), 109 deletions(-) diff --git a/.npmrc b/.npmrc index 449691b..145d3fa 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -save-exact=true \ No newline at end of file +save-exact=true +engine-strict=true diff --git a/.nvmrc b/.nvmrc index a77793e..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/hydrogen +24 diff --git a/package-lock.json b/package-lock.json index 8f4adcd..92f7a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,9 @@ "prettier": "3.4.2", "secretlint": "9.0.0", "set-cookie-parser": "2.7.1" + }, + "engines": { + "node": "24" } }, "node_modules/@ampproject/remapping": { @@ -2534,19 +2537,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/pg": { - "version": "8.11.10", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", - "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^4.0.1" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -9798,14 +9788,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10180,17 +10162,6 @@ "node": ">=4.0.0" } }, - "node_modules/pg-numeric": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", - "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/pg-pool": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", @@ -10206,26 +10177,6 @@ "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", "license": "MIT" }, - "node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -10457,61 +10408,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postgres-array": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", - "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/postgres-range": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", - "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index b2d9c1d..6d83932 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "secretlint": "9.0.0", "set-cookie-parser": "2.7.1" }, + "engines": { + "node": "24" + }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" From 1309bbfb8b9108ad343964b03129cd68a7a0aa4d Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Mon, 16 Feb 2026 08:39:41 +0000 Subject: [PATCH 20/23] 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 9a76893..be5efad 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 279f7d9d476f9205baaca26806daf9c2dd3903f0 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Thu, 12 Mar 2026 05:44:07 +0000 Subject: [PATCH 21/23] chore: add `migration:up:dry` npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6d83932..49ab26e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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:check": "npm run prettier:check && npm run eslint:check", "lint:fix": "npm run prettier:fix", "prettier:check": "prettier --check .", From 2c90056e84eff1ce37e0d5660b05d7bc2503f331 Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Thu, 12 Mar 2026 06:05:57 +0000 Subject: [PATCH 22/23] refactor: improve error loggin in `email.send()` --- infra/email.js | 12 +++++++++++- infra/errors.js | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/infra/email.js b/infra/email.js index e977efe..f91453c 100644 --- a/infra/email.js +++ b/infra/email.js @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { ServiceError } from "./errors"; const transporter = nodemailer.createTransport({ host: process.env.EMAIL_SMTP_HOST, @@ -11,7 +12,16 @@ const transporter = nodemailer.createTransport({ }); async function send(mailOptions) { - await transporter.sendMail(mailOptions); + try { + await transporter.sendMail(mailOptions); + } catch (error) { + throw new ServiceError({ + message: "It was not possble to sent the mail.", + action: "Check if the email service is avalible.", + cause: error, + context: mailOptions, + }); + } } const email = { diff --git a/infra/errors.js b/infra/errors.js index 50cfbb9..d01f92c 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -19,13 +19,14 @@ export class InternalServerError extends Error { } export class ServiceError extends Error { - constructor({ message, cause }) { + constructor({ message, cause, action, context }) { super(message || "Service currently 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() { @@ -34,6 +35,7 @@ export class ServiceError extends Error { message: this.message, action: this.action, status_code: this.statusCode, + context: this.context, }; } } From 5c698b5b9ca94198bbdc85fdf87177d5cd0ad7fe Mon Sep 17 00:00:00 2001 From: isaacisrael Date: Thu, 9 Apr 2026 16:44:57 +0000 Subject: [PATCH 23/23] fix: update email sender address --- models/activation.js | 2 +- tests/integration/_use-cases/registration-flow.test.js | 2 +- tests/integration/infra/email.test.js | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/models/activation.js b/models/activation.js index 9472ad7..b1a2b72 100644 --- a/models/activation.js +++ b/models/activation.js @@ -62,7 +62,7 @@ async function findOneValidById(id) { async function sendEmailToUser(user, activationToken) { await email.send({ - from: "InSystem ", + from: "InSystem ", to: user.email, subject: "Activate you account at InSystem", text: `${user.username} welcome! diff --git a/tests/integration/_use-cases/registration-flow.test.js b/tests/integration/_use-cases/registration-flow.test.js index 0ab4fba..c420bca 100644 --- a/tests/integration/_use-cases/registration-flow.test.js +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -45,7 +45,7 @@ describe("Use case: Registration Flow (all successful)", () => { test("Receive activation email", async () => { const lastEmail = await orchestrator.getLastEmail(); - expect(lastEmail.sender).toBe(""); + expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Activate you account at InSystem"); expect(lastEmail.text).toContain("RegistrationFlow"); diff --git a/tests/integration/infra/email.test.js b/tests/integration/infra/email.test.js index 0be5c1b..bfb6c52 100644 --- a/tests/integration/infra/email.test.js +++ b/tests/integration/infra/email.test.js @@ -9,14 +9,14 @@ describe("infra/email.js", () => { test("send()", async () => { await orchestrator.deleteAllEmails(); await email.send({ - from: "INSystem ", + from: "InSystem ", to: "contato@curso.dev", subject: "Subject test", text: "Body test", }); await email.send({ - from: "INSystem ", + from: "InSystem ", to: "contato@curso.dev", subject: "Last email sent", text: "Body of the last email", @@ -24,7 +24,7 @@ describe("infra/email.js", () => { const lastEmail = await orchestrator.getLastEmail(); - expect(lastEmail.sender).toBe(""); + expect(lastEmail.sender).toBe(""); expect(lastEmail.recipients[0]).toBe(""); expect(lastEmail.subject).toBe("Last email sent"); expect(lastEmail.text).toBe("Body of the last email\n");