diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index ec50013..671d946 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" cache: "npm" - run: npm ci @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" - run: npm ci @@ -45,7 +45,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "20" + node-version-file: "package.json" cache: "npm" - run: npm ci diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 19062f7..5225fb7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "24.14.0" + node-version-file: "package.json" - run: npm ci diff --git a/.nvmrc b/.nvmrc index a77793e..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/hydrogen +24 diff --git a/infra/authorization.js b/infra/authorization.js new file mode 100644 index 0000000..e47a485 --- /dev/null +++ b/infra/authorization.js @@ -0,0 +1,163 @@ +import { InternalServerError } from "./errors"; + +const avalableFeatures = [ + // USER + "create:user", + "read:user", + "read:user:self", + "update:user", + "update:user:others", + + // SESSIONS + "create:session", + "read:session", + + // ACTIVATION_TOKEN + "read:activation_token", + + // MIGRATIONS + "create:migration", + "read:migration", + + // STATUS + "read:status", + "read:status:version", +]; + +function can(user, feature, resource) { + validateUser(user); + validateFeature(feature); + + let authorized = false; + + if (user.features.includes(feature)) { + authorized = true; + } + + if (feature === "update:user" && resource) { + authorized = false; + + if (user.id === resource.id || can(user, "update:user:others")) { + authorized = true; + } + } + + return authorized; +} + +function filterOutput(user, feature, insecureValues) { + validateUser(user); + validateFeature(feature); + validateResource(insecureValues); + + if (feature === "read:user") { + return { + id: insecureValues.id, + username: insecureValues.username, + features: insecureValues.features, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + }; + } + + if (feature === "read:user:self") { + if (user.id === insecureValues.id) { + return { + id: insecureValues.id, + username: insecureValues.username, + email: insecureValues.email, + features: insecureValues.features, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + }; + } + } + + if (feature === "read:session") { + if (user.id === insecureValues.user_id) { + return { + id: insecureValues.id, + token: insecureValues.token, + user_id: insecureValues.user_id, + username: insecureValues.username, + expires_at: insecureValues.expires_at, + created_at: insecureValues.created_at, + updated_at: insecureValues.updated_at, + }; + } + } + + if (feature === "read:activation_token") { + return { + id: insecureValues.id, + user_id: insecureValues.user_id, + created_at: insecureValues.created_at, + update_at: insecureValues.update_at, + expires_at: insecureValues.expires_at, + used_at: insecureValues.used_at, + }; + } + + if (feature === "read:migration") { + return insecureValues.map((migration) => { + return { + path: migration.path, + name: migration.name, + timestamp: migration.timestamp, + }; + }); + } + + if (feature === "read:status") { + const output = { + updated_at: insecureValues.updated_at, + dependencies: { + database: { + max_connections: insecureValues.dependencies.database.max_connections, + open_connections: + insecureValues.dependencies.database.open_connections, + }, + }, + }; + + if (user.features.includes("read:status:version")) { + output.dependencies.database.version = + insecureValues.dependencies.database.version; + } + + return output; + } +} + +function validateUser(user) { + if (!user || !user.features) { + throw new InternalServerError({ + cause: "É necessario fornecer `user` no model `authorization`.", + }); + } +} + +function validateFeature(feature) { + if (!feature || !avalableFeatures.includes(feature)) { + throw new InternalServerError({ + cause: + "É necessario fornecer `feature` conhecida no model `authorization`.", + }); + } +} + +function validateResource(resource) { + if (!resource) { + throw new InternalServerError({ + cause: + "É necessario fornecer `resource`em `authorization.filterOutput()`.", + }); + } +} + +const authorization = { + can, + filterOutput, +}; + +export default authorization; diff --git a/infra/controller.js b/infra/controller.js index e7dec5e..af1a440 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,11 +1,15 @@ -import * as cookie from "cookie"; +import * as cookies from "cookie"; import session from "models/session"; +import user from "models/user"; +import authorization from "./authorization"; + import { InternalServerError, MethodNotAllowedError, ValidationError, NotFoundError, UnauthorizedError, + ForbiddenError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -14,7 +18,11 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { - if (error instanceof ValidationError || error instanceof NotFoundError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ForbiddenError + ) { return response.status(error.statusCode).json(error); } @@ -33,7 +41,7 @@ function onErrorHandler(error, request, response) { } async function setSessionCookie(sessionToken, response) { - const setCookie = cookie.serialize("session_id", sessionToken, { + const setCookie = cookies.serialize("session_id", sessionToken, { path: "/", maxAge: session.EXPIRATION_IN_MILLISECONDS / 1000, secure: process.env.NODE_ENV === "production", @@ -44,7 +52,7 @@ async function setSessionCookie(sessionToken, response) { } async function clearSessionCookie(response) { - const setCookie = cookie.serialize("session_id", "invalid", { + const setCookie = cookies.serialize("session_id", "invalid", { path: "/", maxAge: -1, secure: process.env.NODE_ENV === "production", @@ -54,6 +62,59 @@ async function clearSessionCookie(response) { response.setHeader("Set-Cookie", setCookie); } +async function injectAnonymousOrUser(request, response, next) { + if (request.cookies?.session_id) { + await injectAutenticatedUser(request); + return next(); + } + + injectAnonymousUser(request); + return next(); +} + +async function injectAutenticatedUser(request) { + const sessionToken = request.cookies.session_id; + + const sessionObject = await session.findOneValidByToken(sessionToken); + const userObject = await user.findOneById(sessionObject.user_id); + + request.context = { + ...request.context, + user: userObject, + }; +} + +async function injectAnonymousUser(request) { + const anonymousUserObject = { + features: [ + "read:activation_token", + "read:status", + "create:session", + "create:user", + ], + }; + + request.context = { + ...request.context, + user: anonymousUserObject, + }; +} + +function canRequest(feature) { + return function canRequestMiddleware(request, response, next) { + const userTryingToRequest = request.context.user; + + if (authorization.can(userTryingToRequest, feature)) { + return next(); + } + + throw new ForbiddenError({ + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "${feature}"`, + }); + }; +} + const controller = { errorHandlers: { onNoMatch: onNoMatchHandler, @@ -61,6 +122,9 @@ const controller = { }, setSessionCookie, clearSessionCookie, + injectAnonymousOrUser, + injectAutenticatedUser, + canRequest, }; export default controller; diff --git a/infra/email.js b/infra/email.js index 13d7883..da99224 100644 --- a/infra/email.js +++ b/infra/email.js @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import { ServiceError } from "./errors"; const transporter = nodemailer.createTransport({ host: process.env.EMAIL_SMTP_HOST, @@ -11,7 +12,16 @@ const transporter = nodemailer.createTransport({ }); async function send(mailOptions) { - await transporter.sendMail(mailOptions); + try { + await transporter.sendMail(mailOptions); + } catch (error) { + throw new ServiceError({ + message: "Não foi possivel enviar o email.", + action: "Verifique se o serviço de email está disponivel.", + cause: error, + context: mailOptions, + }); + } } const email = { diff --git a/infra/errors.js b/infra/errors.js index e9c32a9..edbf2e4 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -19,13 +19,14 @@ export class InternalServerError extends Error { } export class ServiceError extends Error { - constructor({ cause, message }) { + constructor({ cause, message, action, context }) { super(message || "Serviço indisponível no momento.", { cause, }); this.name = "InternalServerError"; - this.action = "Entre em contato com o suporte"; + this.action = action || "Entre em contato com o suporte"; this.statusCode = 503; + this.context = context; } toJSON() { @@ -34,6 +35,7 @@ export class ServiceError extends Error { message: this.message, action: this.action, statusCode: this.statusCode, + context: this.context, }; } } @@ -102,6 +104,28 @@ export class UnauthorizedError extends Error { } } +export class ForbiddenError extends Error { + constructor({ cause, message, action }) { + super(message || "Acesso negado.", { + cause, + }); + + this.name = "ForbiddenError"; + this.action = + action || "Verifique as features necessárias antes de continuar."; + this.statusCode = 403; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + statusCode: this.statusCode, + }; + } +} + export class MethodNotAllowedError extends Error { constructor() { super("Metodo não permitido para este endpoint."); diff --git a/infra/migrations/1781880952907_add-features-to-users.js b/infra/migrations/1781880952907_add-features-to-users.js new file mode 100644 index 0000000..f2d73c4 --- /dev/null +++ b/infra/migrations/1781880952907_add-features-to-users.js @@ -0,0 +1,11 @@ +exports.up = (pgm) => { + pgm.addColumn("users", { + features: { + type: "varchar[]", + notNull: true, + default: "{}", + }, + }); +}; + +exports.down = false; diff --git a/infra/migrations/1782143252549_create-user-activation-tokens.js b/infra/migrations/1782143252549_create-user-activation-tokens.js new file mode 100644 index 0000000..222f233 --- /dev/null +++ b/infra/migrations/1782143252549_create-user-activation-tokens.js @@ -0,0 +1,36 @@ +exports.up = (pgm) => { + pgm.createTable("user_activation_tokens", { + id: { + type: "uuid", + primaryKey: true, + default: pgm.func("gen_random_uuid()"), + }, + + used_at: { + type: "timestamptz", + notNull: false, + }, + + user_id: { + type: "uuid", + notNull: true, + }, + + expires_at: { + type: "timestamptz", + notNull: true, + }, + + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + + updated_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + }); +}; diff --git a/infra/webserver.js b/infra/webserver.js new file mode 100644 index 0000000..e83ee65 --- /dev/null +++ b/infra/webserver.js @@ -0,0 +1,17 @@ +function getOrigin() { + if (["test", "development"].includes(process.env.NODE_ENV)) { + return "http//localhost:3000"; + } + + if (process.env.VERCEL_ENV === "preview") { + return `http//${process.env.VERCEL_ENV}`; + } + + return "https://xedit.com.br"; +} + +const webServer = { + origin: getOrigin(), +}; + +export default webServer; diff --git a/models/activation.js b/models/activation.js new file mode 100644 index 0000000..7d9e7fb --- /dev/null +++ b/models/activation.js @@ -0,0 +1,136 @@ +import email from "infra/email"; +import database from "infra/database"; +import webServer from "infra/webserver"; +import { ForbiddenError, NotFoundError } from "infra/errors"; +import user from "models/user"; +import authorization from "infra/authorization"; + +const EXPIRATION_IN_MILLISECONDS = 60 * 15 * 1000; // 15 minutes + +async function findOneValidById(tokenId) { + const activationTokenObject = await runSelectQuery(tokenId); + return activationTokenObject; + + async function runSelectQuery(tokenId) { + const results = await database.query({ + text: ` + SELECT + * + FROM + user_activation_tokens + WHERE + id = $1 + AND expires_at > NOW() + AND used_at IS NULL + LIMIT + 1 + ;`, + values: [tokenId], + }); + + if (results.rowCount === 0) { + throw new NotFoundError({ + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + }); + } + + return results.rows[0]; + } +} + +async function create(userId) { + const expiresAt = new Date(Date.now() + EXPIRATION_IN_MILLISECONDS); + + const newToken = await runInsertQuery(userId, expiresAt); + return newToken; + + async function runInsertQuery(userId, expiresAt) { + const results = await database.query({ + text: ` + INSERT INTO + user_activation_tokens (user_id, expires_at) + VALUES + ($1, $2) + RETURNING + * + ;`, + values: [userId, expiresAt], + }); + + return results.rows[0]; + } +} + +async function sendEmailToUser(user, activationToken) { + await email.send({ + from: "Xedit ", + to: user.email, + subject: "Ative seu cadastro no Xedit!", + text: ` +Olá, ${user.username}! + +Clique no link abaixo para ativar seu cadastro no Xedit: + +${webServer.origin}/cadastro/ativar/${activationToken.id} + +Se você não solicitou este cadastro, ignore este email. + +Atenciosamente, +Equipe Xedit +`.trim(), + }); +} + +async function markTokenAsUsed(activationTokenId) { + const activationTokenObject = await runUpdateQuery(activationTokenId); + return activationTokenObject; + + async function runUpdateQuery(activationTokenId) { + const results = await database.query({ + text: ` + UPDATE + user_activation_tokens + SET + used_at = timezone('utc', now()), + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [activationTokenId], + }); + + return results.rows[0]; + } +} + +async function activateUserByUserId(userId) { + const userToActivate = await user.findOneById(userId); + + if (!authorization.can(userToActivate, "read:activation_token")) { + throw new ForbiddenError({ + message: "Você não pode mais utlizar tokens de ativação.", + action: "Entre em contato com o suporte.", + }); + } + + const activatedUser = await user.setFeature(userId, [ + "create:session", + "read:session", + "update:user", + ]); + return activatedUser; +} + +const activation = { + sendEmailToUser, + create, + findOneValidById, + markTokenAsUsed, + activateUserByUserId, +}; + +export default activation; diff --git a/models/user.js b/models/user.js index 5eb7cff..e132ee9 100644 --- a/models/user.js +++ b/models/user.js @@ -99,6 +99,7 @@ async function create(userInputValue) { await validateUniqueEmail(userInputValue.email); await validateUniqueUsername(userInputValue.username); await hashPasswordInObject(userInputValue); + injectDefaultFeaturesInObject(userInputValue); const newUser = await runInsertQuery(userInputValue); return newUser; @@ -107,9 +108,9 @@ async function create(userInputValue) { const result = await database.query({ text: ` INSERT INTO - users (username, email, password) + users (username, email, password, features) VALUES - ($1, $2, $3) + ($1, $2, $3, $4) RETURNING * ;`, @@ -117,10 +118,15 @@ async function create(userInputValue) { userInputValue.username, userInputValue.email, userInputValue.password, + userInputValue.features, ], }); return result.rows[0]; } + + function injectDefaultFeaturesInObject(userInputValue) { + userInputValue.features = ["read:activation_token"]; + } } async function update(username, userInputValue) { @@ -217,12 +223,74 @@ async function hashPasswordInObject(userInputValue) { userInputValue.password = hashedPassword; } +async function setFeature(userId, features) { + const updateUser = await runUpdateQuery(userId, features); + return updateUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = $2, + update_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "Usuário não encontrado.", + action: "Verifique o identificador informado.", + }); + } + + return results.rows[0]; + } +} + +async function addFeatures(userId, features) { + const updateUser = await runUpdateQuery(userId, features); + return updateUser; + + async function runUpdateQuery(userId, features) { + const results = await database.query({ + text: ` + UPDATE + users + SET + features = array_cat(features, $2), + update_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + ;`, + values: [userId, features], + }); + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "Usuário não encontrado.", + action: "Verifique o identificador informado.", + }); + } + + return results.rows[0]; + } +} + const user = { create, + update, + setFeature, + addFeatures, findOneById, findOneByUsername, findOneByEmail, - update, }; export default user; diff --git a/package-lock.json b/package-lock.json index 686108c..aa748d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,9 @@ "npm-run-all": "^4.1.5", "prettier": "3.8.3", "set-cookie-parser": "2.7.1" + }, + "engines": { + "node": "24" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index d37db71..019b189 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "services:wait:database": "node infra/scripts/wait-for-postgres.js", "migrations:create": "node-pg-migrate -m infra/migrations create", "migrations:up": "node-pg-migrate -m infra/migrations --envPath .env.development up", + "migrations:up:dry": "node-pg-migrate -m infra/migrations --envPath .env.development --dry-run up", "lint:prettier:check": "prettier --check .", "lint:prettier:fix": "prettier --write .", "lint:eslint:check": "eslint .", @@ -63,5 +64,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "engines": { + "node": "24" } } 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..e668f8f --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,32 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import activation from "models/activation"; +import authorization from "infra/authorization"; + +const router = createRouter(); + +router.use(controller.injectAnonymousOrUser); +router.patch(controller.canRequest("read:activation_token"), patchHandler); + +export default router.handler(controller.errorHandlers); + +async function patchHandler(request, response) { + const userTryingToPatch = request.context.user; + const activationTokenId = request.query.token_id; + + const validActivationToken = + await activation.findOneValidById(activationTokenId); + + await activation.activateUserByUserId(validActivationToken.user_id); + + const usedActivationToken = + await activation.markTokenAsUsed(activationTokenId); + + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:activation_token", + usedActivationToken, + ); + + return response.status(200).json(secureOutputValues); +} diff --git a/pages/api/v1/migrations/index.js b/pages/api/v1/migrations/index.js index 281066a..7bc57a6 100644 --- a/pages/api/v1/migrations/index.js +++ b/pages/api/v1/migrations/index.js @@ -1,25 +1,42 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import migrator from "models/migrator"; +import authorization from "infra/authorization"; const router = createRouter(); -router.get(getHandler); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:migration"), getHandler); +router.post(controller.canRequest("create:migration"), postHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const pendingMigrations = await migrator.listPendingMigrations(); - return response.status(200).json(pendingMigrations); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:migration", + pendingMigrations, + ); + + return response.status(200).json(secureOutputValues); } async function postHandler(request, response) { + const userTryingToPost = request.context.user; const migratedMigrations = await migrator.runPendingMigrations(); - if (migratedMigrations.length > 0) { - return response.status(201).json(migratedMigrations); + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:migration", + migratedMigrations, + ); + + if (secureOutputValues.length > 0) { + return response.status(201).json(secureOutputValues); } - return response.status(200).json(migratedMigrations); + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/sessions/index.js b/pages/api/v1/sessions/index.js index 331b4c9..9c0f64e 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -1,11 +1,15 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import authentication from "models/authentication"; +import authorization from "infra/authorization"; import session from "models/session"; +import { ForbiddenError } from "infra/errors"; + const router = createRouter(); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:session"), postHandler); router.delete(deleteHandler); export default router.handler(controller.errorHandlers); @@ -18,19 +22,38 @@ async function postHandler(request, response) { userInputValue.password, ); - const newSession = await session.create(authenticatedUser.id); + if (!authorization.can(authenticatedUser, "create:session")) { + throw new ForbiddenError({ + message: "Você não possui permissão para fazer login.", + action: "Contate o suporte para caso tenha alguma dúvida.", + }); + } + const newSession = await session.create(authenticatedUser.id); controller.setSessionCookie(newSession.token, response); - return response.status(201).json(newSession); + const secureOutputValues = authorization.filterOutput( + authenticatedUser, + "read:session", + newSession, + ); + + return response.status(201).json(secureOutputValues); } -async function deleteHandler(resquest, response) { - const sessionToken = resquest.cookies.session_id; +async function deleteHandler(request, response) { + const sessionToken = request.cookies.session_id; + const userTryingDelete = request.context.user; const sessionObject = await session.findOneValidByToken(sessionToken); const expiredSession = await session.expireById(sessionObject.id); controller.clearSessionCookie(response); - return response.status(200).json(expiredSession); + const secureOutputValues = authorization.filterOutput( + userTryingDelete, + "read:session", + expiredSession, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/status/index.js b/pages/api/v1/status/index.js index bccf2a1..627c1f5 100644 --- a/pages/api/v1/status/index.js +++ b/pages/api/v1/status/index.js @@ -2,21 +2,24 @@ import { createRouter } from "next-connect"; import database from "infra/database.js"; import controller from "infra/controller"; +import authorization from "infra/authorization"; const router = createRouter(); +router.use(controller.injectAnonymousOrUser); router.get(getHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const updatedAt = new Date().toISOString(); const databaseVersionResult = await database.query("SHOW server_version;"); const databaseVersionValue = databaseVersionResult.rows[0].server_version; const databaseMaxConnectionsResult = await database.query( - "SHOW max_connections", + "SHOW max_connections;", ); const databaseMaxConnectionsValue = @@ -30,7 +33,7 @@ async function getHandler(request, response) { const databaseOpenConnectionsValue = databaseOpenConnectionsResult.rows[0].count; - response.status(200).json({ + const status = { updated_at: updatedAt, dependencies: { database: { @@ -39,5 +42,13 @@ async function getHandler(request, response) { open_connections: databaseOpenConnectionsValue, }, }, - }); + }; + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:status", + status, + ); + + response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/user/index.js b/pages/api/v1/user/index.js index cbc8e1c..b8b6eac 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -2,14 +2,17 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; import session from "models/session"; +import authorization from "infra/authorization"; const router = createRouter(); -router.get(getHandler); +router.use(controller.injectAnonymousOrUser); +router.get(controller.canRequest("read:session"), getHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const sessionToken = request.cookies.session_id; const sessionObject = await session.findOneValidByToken(sessionToken); @@ -23,5 +26,12 @@ async function getHandler(request, response) { "Cache-Control", "no-store , max-age = 0, must-revalidate", ); - return response.status(200).json(userFound); + + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user:self", + userFound, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index cf6cb8c..25a4483 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -1,25 +1,53 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import authorization from "infra/authorization"; +import { ForbiddenError } from "infra/errors"; const router = createRouter(); +router.use(controller.injectAnonymousOrUser); router.get(getHandler); -router.patch(patchHandler); +router.patch(controller.canRequest("update:user"), patchHandler); export default router.handler(controller.errorHandlers); async function getHandler(request, response) { + const userTryingToGet = request.context.user; const username = request.query.username; const userFound = await user.findOneByUsername(username); - return response.status(200).json(userFound); + const secureOutputValues = authorization.filterOutput( + userTryingToGet, + "read:user", + userFound, + ); + + return response.status(200).json(secureOutputValues); } async function patchHandler(request, response) { const username = request.query.username; const userInputValue = request.body; + const userTryingToPatch = request.context.user; + const targetUser = await user.findOneByUsername(username); + + if (!authorization.can(userTryingToPatch, "update:user", targetUser)) { + throw new ForbiddenError({ + message: "Você não possui permissão para atualizar outro usuário.", + action: + "Verifique se você possui a feature necessária para atualizar outro usuario.", + }); + } + const updatedUser = await user.update(username, userInputValue); - return response.status(200).json(updatedUser); + + const secureOutputValues = authorization.filterOutput( + userTryingToPatch, + "read:user", + updatedUser, + ); + + return response.status(200).json(secureOutputValues); } diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js index a58754a..59e054f 100644 --- a/pages/api/v1/users/index.js +++ b/pages/api/v1/users/index.js @@ -1,15 +1,29 @@ import { createRouter } from "next-connect"; import controller from "infra/controller"; import user from "models/user"; +import activation from "models/activation"; +import authorization from "infra/authorization"; const router = createRouter(); -router.post(postHandler); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:user"), postHandler); export default router.handler(controller.errorHandlers); async function postHandler(request, response) { + const userTryingToPost = request.context.user; const userInputValue = request.body; const newUser = await user.create(userInputValue); - return response.status(201).json(newUser); + + const activationToken = await activation.create(newUser.id); + await activation.sendEmailToUser(newUser, activationToken); + + const secureOutputValues = authorization.filterOutput( + userTryingToPost, + "read:user", + newUser, + ); + + return response.status(201).json(secureOutputValues); } diff --git a/tests/integration/_user-case/registration-flow.test.js b/tests/integration/_user-case/registration-flow.test.js new file mode 100644 index 0000000..e0c1d80 --- /dev/null +++ b/tests/integration/_user-case/registration-flow.test.js @@ -0,0 +1,115 @@ +import orchestrator from "tests/orchestrator"; +import webServer from "infra/webserver"; +import activation from "models/activation"; +import user from "models/user"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); + await orchestrator.deleteAllEmails(); +}); + +describe("Use case: Registration Flow (all successful)", () => { + let createUserResponseBody; + let activationTokenId; + let createSessionsResponseBody; + + test("Create user account", async () => { + const createUserResponse = await fetch( + "http://localhost:3000/api/v1/users", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "RegistrationFlow", + email: "registraion.flow@xedit.com", + password: "RegistrationFlowPassword", + }), + }, + ); + expect(createUserResponse.status).toBe(201); + + createUserResponseBody = await createUserResponse.json(); + expect(createUserResponseBody).toEqual({ + id: createUserResponseBody.id, + username: "RegistrationFlow", + features: ["read:activation_token"], + created_at: createUserResponseBody.created_at, + update_at: createUserResponseBody.update_at, + }); + }); + + test("Receive activation email", async () => { + const lastEmail = await orchestrator.getLastEmail(); + + expect(lastEmail.sender).toBe(""); + expect(lastEmail.recipients[0]).toBe(""); + expect(lastEmail.subject).toBe("Ative seu cadastro no Xedit!"); + expect(lastEmail.text).toContain("RegistrationFlow"); + + activationTokenId = orchestrator.extractUUID(lastEmail.text); + expect(lastEmail.text).toContain( + `${webServer.origin}/cadastro/ativar/${activationTokenId}`, + ); + + const activationTokenObject = + await activation.findOneValidById(activationTokenId); + expect(activationTokenObject.user_id).toBe(createUserResponseBody.id); + expect(activationTokenObject.used_at).toBe(null); + }); + + test("Activate account", async () => { + const activationResponse = await fetch( + `http://localhost:3000/api/v1/activations/${activationTokenId}`, + { + method: "PATCH", + }, + ); + expect(activationResponse.status).toBe(200); + + const activationResponseBody = await activationResponse.json(); + expect(Date.parse(activationResponseBody.used_at)).not.toBeNaN(); + + const activatedUser = await user.findOneByUsername("RegistrationFlow"); + expect(activatedUser.features).toEqual([ + "create:session", + "read:session", + "update:user", + ]); + }); + + test("Login", async () => { + const createSessionsResponse = await fetch( + "http://localhost:3000/api/v1/sessions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "registraion.flow@xedit.com", + password: "RegistrationFlowPassword", + }), + }, + ); + expect(createSessionsResponse.status).toBe(201); + + createSessionsResponseBody = await createSessionsResponse.json(); + expect(createSessionsResponseBody.user_id).toBe(createUserResponseBody.id); + }); + + test("Get user information", async () => { + const userResponse = await fetch("http://localhost:3000/api/v1/user", { + headers: { + cookie: `session_id=${createSessionsResponseBody.token}`, + }, + }); + expect(userResponse.status).toBe(200); + + const userResponseBody = await userResponse.json(); + expect(userResponseBody.id).toBe(createUserResponseBody.id); + }); +}); diff --git a/tests/integration/api/v1/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js new file mode 100644 index 0000000..8c04992 --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,117 @@ +import orchestrator from "../../../../../orchestrator"; + +import activation from "models/activation"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH to /api/v1/activations/[token_id]", () => { + describe("Anonymous user", () => { + test("With nonexistent token", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/activations/5464bcba-2345-423e-8334-988fd17ee71e", + { + method: "PATCH", + }, + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "NotFoundError", + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + statusCode: 404, + }); + }); + + test("With already used token", async () => { + const createdUser = await orchestrator.createUser(); + const activationToken = await activation.create(createdUser.id); + + const response1 = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response1.status).toBe(200); + + const response2 = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response2.status).toBe(404); + + const response2Body = await response2.json(); + expect(response2Body).toEqual({ + name: "NotFoundError", + message: + "O Token de ativação utilizado não foi encontrado no sistema ou expirou.", + action: "Faça um novo cadastro.", + statusCode: 404, + }); + }); + + test("With valid token but alreadt activates user", async () => { + const createUser = await orchestrator.createUser(); + await orchestrator.activateUser(createUser); + const activationToken = await activation.create(createUser.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${activationToken.id}`, + { + method: "PATCH", + }, + ); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não pode mais utlizar tokens de ativação.", + action: "Entre em contato com o suporte.", + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { + test("With valid token, but already logged in user", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1SessionObject = await orchestrator.createSession(user1.id); + + const user2 = await orchestrator.createUser(); + const user2ActivationToken = await activation.create(user2.id); + + const response = await fetch( + `http://localhost:3000/api/v1/activations/${user2ActivationToken.id}`, + { + method: "PATCH", + headers: { + Cookie: `session_id=${user1SessionObject.token}`, + }, + }, + ); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: + 'Verifique se o seu usuário possui a feature "read:activation_token"', + statusCode: 403, + }); + }); + }); +}); diff --git a/tests/integration/api/v1/migrations/get.test.js b/tests/integration/api/v1/migrations/get.test.js index c9502c5..e8582de 100644 --- a/tests/integration/api/v1/migrations/get.test.js +++ b/tests/integration/api/v1/migrations/get.test.js @@ -3,18 +3,66 @@ import orchestrator from "../../../../orchestrator"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("GET to /api/v1/migrations", () => { describe("Anonymous user", () => { test("Retrieving pending migrations", async () => { const response = await fetch("http://localhost:3000/api/v1/migrations"); - expect(response.status).toBe(200); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:migration"`, + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { + test("Retrieving pending migrations", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + + expect(response.status).toBe(403); const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "read:migration"', + statusCode: 403, + }); + }); + }); + describe("Privileged user", () => { + test("With `read:migration`", async () => { + const createdUser = await orchestrator.createUser({}); + const activatedUser = await orchestrator.activateUser(createdUser); + await orchestrator.addFeaturesToUser(createdUser, ["read:migration"]); + const sessionObject = await orchestrator.createSession(activatedUser.id); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); expect(Array.isArray(responseBody)).toBe(true); - expect(responseBody.length).toBeGreaterThan(0); }); }); }); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 15f7575..ab501b6 100644 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,39 +3,67 @@ import orchestrator from "../../../../orchestrator"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("POST to /api/v1/migrations", () => { describe("Anonymous user", () => { - describe("Running pending migrations", () => { - test("For the fisrt time", async () => { - const response1 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response1.status).toBe(201); - - const response1Body = await response1.json(); - expect(Array.isArray(response1Body)).toBe(true); - expect(response1Body.length).toBeGreaterThan(0); + test("Retrieving pending migrations", async () => { + const response = await fetch("http://localhost:3000/api/v1/migrations"); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:migration"`, + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { + test("Retrieving pending migrations", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); + + const response = await fetch("http://localhost:3000/api/v1/migrations", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, }); - test("For the sercond time", async () => { - const response2 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - expect(response2.status).toBe(200); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "read:migration"', + statusCode: 403, + }); + }); + }); - const response2Body = await response2.json(); + describe("Privileged user", () => { + test("With `create:migration`", async () => { + const createdUser = await orchestrator.createUser({}); + const activatedUser = await orchestrator.activateUser(createdUser); + await orchestrator.addFeaturesToUser(createdUser, ["create:migration"]); + const sessionObject = await orchestrator.createSession(activatedUser.id); - expect(Array.isArray(response2Body)).toBe(true); - expect(response2Body.length).toBe(0); + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(Array.isArray(responseBody)).toBe(true); }); }); }); diff --git a/tests/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js index b0e7543..81aec28 100644 --- a/tests/integration/api/v1/sessions/post.test.js +++ b/tests/integration/api/v1/sessions/post.test.js @@ -95,6 +95,8 @@ describe("POST to /api/v1/sessions", () => { password: "tudocorreto", }); + await orchestrator.activateUser(createdUser); + const response = await fetch("http://localhost:3000/api/v1/sessions", { method: "POST", headers: { diff --git a/tests/integration/api/v1/status/get.test.js b/tests/integration/api/v1/status/get.test.js index 30623b3..7a4444f 100644 --- a/tests/integration/api/v1/status/get.test.js +++ b/tests/integration/api/v1/status/get.test.js @@ -6,18 +6,93 @@ beforeAll(async () => { describe("GET to /api/v1/status", () => { describe("Anonymous user", () => { - test("Rertieving current system status", async () => { + test("Retrieving current system status", async () => { const response = await fetch("http://localhost:3000/api/v1/status"); expect(response.status).toBe(200); const responseBody = await response.json(); + const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); + expect(responseBody.updated_at).toEqual(parsedUpdateAt); + + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); + }); + }); + + describe("Default use user", () => { + test("Retrieving current system status", async () => { + const createUser = await orchestrator.createUser({}); + await orchestrator.activateUser(createUser); + + const response = await fetch("http://localhost:3000/api/v1/status"); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); + + expect(responseBody.updated_at).toEqual(parsedUpdateAt); + expect(responseBody.dependencies.database.max_connections).toEqual(100); + expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); + }); + }); + describe("Privileged user", () => { + test("With `read:status:version`", async () => { + const createdUser = await orchestrator.createUser({}); + const activatedUser = await orchestrator.activateUser(createdUser); + + await orchestrator.addFeaturesToUser(activatedUser, [ + "read:status", + "read:status:version", + ]); + + const sessionObject = await orchestrator.createSession(activatedUser.id); + + const response = await fetch("http://localhost:3000/api/v1/status", { + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, + }); + expect(response.status).toBe(200); + + const responseBody = await response.json(); const parsedUpdateAt = new Date(responseBody.updated_at).toISOString(); expect(responseBody.updated_at).toEqual(parsedUpdateAt); expect(responseBody.dependencies.database.version).toEqual("16.13"); expect(responseBody.dependencies.database.max_connections).toEqual(100); expect(responseBody.dependencies.database.open_connections).toEqual(1); + expect(responseBody).toEqual({ + dependencies: { + database: { + version: responseBody.dependencies.database.version, + max_connections: responseBody.dependencies.database.max_connections, + open_connections: + responseBody.dependencies.database.open_connections, + }, + }, + updated_at: responseBody.updated_at, + }); }); }); }); diff --git a/tests/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js index 3beb826..19e4821 100644 --- a/tests/integration/api/v1/user/get.test.js +++ b/tests/integration/api/v1/user/get.test.js @@ -10,12 +10,29 @@ beforeAll(async () => { }); describe("GET to /api/v1/user", () => { + describe("Anonymous user", () => { + test("Retrienving the endpoint", async () => { + const response = await fetch("http://localhost:3000/api/v1/user"); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: `Verifique se o seu usuário possui a feature "read:session"`, + statusCode: 403, + }); + }); + }); + describe("Default user", () => { test("With valid session", async () => { const createUser = await orchestrator.createUser({ username: "UserWithValidSession", }); + const activatedUser = await orchestrator.activateUser(createUser); const sessionObject = await orchestrator.createSession(createUser.id); const response = await fetch("http://localhost:3000/api/v1/user", { @@ -35,9 +52,9 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithValidSession", email: createUser.email, - password: createUser.password, + features: ["create:session", "read:session", "update:user"], created_at: createUser.created_at.toISOString(), - update_at: createUser.update_at.toISOString(), + update_at: activatedUser.update_at.toISOString(), }); expect(uuidVersion(responseBody.id)).toEqual(4); @@ -187,6 +204,8 @@ describe("GET to /api/v1/user", () => { username: "UserWithSession", }); + const activatedUser = await orchestrator.activateUser(createUser); + const sessionObject = await orchestrator.createSession(createUser.id); jest.useRealTimers(); @@ -205,9 +224,9 @@ describe("GET to /api/v1/user", () => { id: createUser.id, username: "UserWithSession", email: createUser.email, - password: createUser.password, + features: ["create:session", "read:session", "update:user"], created_at: createUser.created_at.toISOString(), - update_at: createUser.update_at.toISOString(), + update_at: activatedUser.update_at.toISOString(), }); }); }); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 63d2590..a54a827 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -26,8 +26,7 @@ describe("GET to /api/v1/users/[username]", () => { expect(response2Body).toEqual({ id: response2Body.id, username: "MesmoCase", - email: "mesmo.case@gmail.com", - password: response2Body.password, + features: ["read:activation_token"], created_at: response2Body.created_at, update_at: response2Body.update_at, }); @@ -40,8 +39,6 @@ describe("GET to /api/v1/users/[username]", () => { test("With case mismatch", async () => { await orchestrator.createUser({ username: "CaseDiferente", - email: "case.diferente@gmail.com", - password: "senha123", }); const response2 = await fetch( @@ -54,8 +51,7 @@ describe("GET to /api/v1/users/[username]", () => { expect(response2Body).toEqual({ id: response2Body.id, username: "CaseDiferente", - email: "case.diferente@gmail.com", - password: response2Body.password, + features: ["read:activation_token"], created_at: response2Body.created_at, update_at: response2Body.update_at, }); diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js index c125a70..91ca1d9 100644 --- a/tests/integration/api/v1/users/[username]/patch.test.js +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -11,17 +11,51 @@ beforeAll(async () => { describe("PATCH to /api/v1/users/[username]", () => { describe("Anonymous user", () => { + test("With unique 'username'", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueUser1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser2", + }), + }, + ); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + action: 'Verifique se o seu usuário possui a feature "update:user"', + message: "Você não possui permissão para executar esta ação.", + name: "ForbiddenError", + statusCode: 403, + }); + }); + }); + + describe("Default user", () => { test("With nonesistent 'username'", async () => { + const createdUser = await orchestrator.createUser(); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); + const response = await fetch( "http://localhost:3000/api/v1/users/UsuarioInexistente", { method: "PATCH", + headers: { + Cookie: `session_id=${sessionObject.token}`, + }, }, ); expect(response.status).toBe(404); const responseBody = await response.json(); - expect(responseBody).toEqual({ name: "NotFoundError", message: "O username informando não foi encotrado no sistema.", @@ -35,14 +69,20 @@ describe("PATCH to /api/v1/users/[username]", () => { username: "user1", }); - await orchestrator.createUser({ + const createdUser2 = await orchestrator.createUser({ username: "user2", }); + const activatedUser2 = await orchestrator.activateUser(createdUser2); + const sessionObject2 = await orchestrator.createSession( + activatedUser2.id, + ); + const response = await fetch("http://localhost:3000/api/v1/users/user2", { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ username: "user1", @@ -60,31 +100,72 @@ describe("PATCH to /api/v1/users/[username]", () => { }); }); + test("With `userB` targeting `userA`", async () => { + await orchestrator.createUser({ + username: "userA", + }); + + const createdUserB = await orchestrator.createUser({ + username: "userB", + }); + + const activatedUserB = await orchestrator.activateUser(createdUserB); + const sessionObjectB = await orchestrator.createSession( + activatedUserB.id, + ); + + const response = await fetch("http://localhost:3000/api/v1/users/userA", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${sessionObjectB.token}`, + }, + body: JSON.stringify({ + username: "userC", + }), + }); + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + action: + "Verifique se você possui a feature necessária para atualizar outro usuario.", + message: "Você não possui permissão para atualizar outro usuário.", + name: "ForbiddenError", + statusCode: 403, + }); + }); + test("With duplicated 'email'", async () => { await orchestrator.createUser({ - email: "email1@gmail.com", + email: "email1@xedit.com", }); - const createUser2 = await orchestrator.createUser({ - email: "email2@gmail.com", + const createdUser2 = await orchestrator.createUser({ + email: "email2@xedit.com", }); + const activatedUser2 = await orchestrator.activateUser(createdUser2); + const sessionObject2 = await orchestrator.createSession( + activatedUser2.id, + ); + const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser2.username}`, + `http://localhost:3000/api/v1/users/${createdUser2.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject2.token}`, }, body: JSON.stringify({ - email: "email1@gmail.com", + email: "email1@xedit.com", }), }, ); expect(response.status).toBe(400); const responseBody = await response.json(); - expect(responseBody).toEqual({ name: "ValidationError", message: "O email informado já está sendo utilizando.", @@ -94,9 +175,11 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With unique 'username'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ username: "uniqueUser1", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( "http://localhost:3000/api/v1/users/uniqueUser1", @@ -104,22 +187,20 @@ describe("PATCH to /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser2", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, username: "uniqueUser2", - email: createUser.email, - password: responseBody.password, + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -132,32 +213,32 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With unique 'email'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ email: "uniqueEmail1@gmail.com", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser.username}`, + `http://localhost:3000/api/v1/users/${createdUser.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ email: "uniqueEmail2@gmail.com", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, - username: createUser.username, - email: "uniqueEmail2@gmail.com", - password: responseBody.password, + username: createdUser.username, + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -170,32 +251,32 @@ describe("PATCH to /api/v1/users/[username]", () => { }); test("With new 'password'", async () => { - const createUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createUser({ password: "newPassword1", }); + const activatedUser = await orchestrator.activateUser(createdUser); + const sessionObject = await orchestrator.createSession(activatedUser.id); const response = await fetch( - `http://localhost:3000/api/v1/users/${createUser.username}`, + `http://localhost:3000/api/v1/users/${createdUser.username}`, { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword2", }), }, ); - expect(response.status).toBe(200); const responseBody = await response.json(); - expect(responseBody).toEqual({ id: responseBody.id, - username: createUser.username, - email: createUser.email, - password: responseBody.password, + username: createdUser.username, + features: ["create:session", "read:session", "update:user"], created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -206,14 +287,14 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(responseBody.update_at > responseBody.created_at).toBe(true); - const userInDatabase = await user.findOneByUsername(createUser.username); + const userInDatabase = await user.findOneByUsername(createdUser.username); const correctPasswordMatch = await password.compare( "newPassword2", userInDatabase.password, ); const incorrectPasswordMatch = await password.compare( - createUser.username, + createdUser.username, userInDatabase.password, ); @@ -221,4 +302,52 @@ describe("PATCH to /api/v1/users/[username]", () => { expect(incorrectPasswordMatch).toBe(false); }); }); + + describe("Privileged user", () => { + test("With `update:others` targeting `defaultUser`", async () => { + const privilegedUser = await orchestrator.createUser(); + const activatedPrivilegedUser = + await orchestrator.activateUser(privilegedUser); + + await orchestrator.addFeaturesToUser(privilegedUser, [ + "update:user:others", + ]); + + const privilegedUsersession = await orchestrator.createSession( + activatedPrivilegedUser.id, + ); + + const defaultUser = await orchestrator.createUser(); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${defaultUser.username}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${privilegedUsersession.token}`, + }, + body: JSON.stringify({ + username: "AlteradoPorPrivilegiado", + }), + }, + ); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: defaultUser.id, + username: "AlteradoPorPrivilegiado", + features: defaultUser.features, + created_at: responseBody.created_at, + update_at: responseBody.update_at, + }); + + expect(uuidVersion(responseBody.id)).toEqual(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.update_at)).not.toBeNaN(); + + expect(responseBody.update_at > responseBody.created_at).toBe(true); + }); + }); }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 7f61843..f1ac5b7 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -31,8 +31,7 @@ describe("POST to /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "guilhermeMarinho", - email: "guimars@gmail.com", - password: responseBody.password, + features: ["read:activation_token"], created_at: responseBody.created_at, update_at: responseBody.update_at, }); @@ -134,4 +133,36 @@ describe("POST to /api/v1/users", () => { }); }); }); + + describe("Default user", () => { + test("With unique and valid data", async () => { + const user1 = await orchestrator.createUser(); + await orchestrator.activateUser(user1); + const user1SessionOject = await orchestrator.createSession(user1.id); + + const user2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: `session_id=${user1SessionOject.token}`, + }, + body: JSON.stringify({ + username: "usueriologado", + email: "usueriologado@xedit.com", + password: "senha123", + }), + }); + + expect(user2Response.status).toBe(403); + + const user2ResponseBody = await user2Response.json(); + + expect(user2ResponseBody).toEqual({ + name: "ForbiddenError", + message: "Você não possui permissão para executar esta ação.", + action: 'Verifique se o seu usuário possui a feature "create:user"', + statusCode: 403, + }); + }); + }); }); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index dfeaa4a..786d0e4 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -2,6 +2,7 @@ import retry from "async-retry"; import { faker } from "@faker-js/faker"; import dotenv from "dotenv"; +import activation from "models/activation"; import database from "infra/database"; import migrator from "models/migrator"; import user from "models/user"; @@ -63,12 +64,11 @@ async function runPendingMigrations() { await migrator.runPendingMigrations(); } -async function createUser(userObject) { +async function createUser({ username, email, password } = {}) { return await user.create({ - username: - userObject.username || faker.internet.username().replace(/[_.-]/g, ""), - email: userObject.email || faker.internet.email(), - password: userObject.password || "validpassword", + username: username || faker.internet.username().replace(/[_.-]/g, ""), + email: email || faker.internet.email(), + password: password || "validpassword", }); } @@ -83,6 +83,10 @@ async function getLastEmail() { const emailListBody = await emailListResponse.json(); const lastEmailItem = emailListBody.pop(); + if (!lastEmailItem) { + return null; + } + const emailTextResponse = await fetch( `${emailHttpUrl}/messages/${lastEmailItem.id}.plain`, ); @@ -92,6 +96,20 @@ async function getLastEmail() { return lastEmailItem; } +function extractUUID(text) { + const match = text.match(/[0-9a-fA-F-]{36}/); + return match ? match[0] : null; +} + +async function activateUser(inactiveUser) { + return await activation.activateUserByUserId(inactiveUser.id); +} + +async function addFeaturesToUser(userObject, feature) { + const updateUser = await user.addFeatures(userObject.id, feature); + return updateUser; +} + const orchestrator = { waitForAllServices, clearDatabase, @@ -100,6 +118,9 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, + activateUser, + addFeaturesToUser, }; export default orchestrator; diff --git a/tests/unit/models/authorization.test.js b/tests/unit/models/authorization.test.js new file mode 100644 index 0000000..4ec42b3 --- /dev/null +++ b/tests/unit/models/authorization.test.js @@ -0,0 +1,150 @@ +import authorization from "infra/authorization"; +import { InternalServerError } from "infra/errors"; + +describe("models/authorization.js", () => { + describe(".can()", () => { + test("without `user`", () => { + expect(() => { + authorization.can(); + }).toThrow(InternalServerError); + }); + + test("without `user.features`", () => { + const createdUser = { + username: "UserWithoutFeatures", + }; + + expect(() => { + authorization.can(createdUser); + }).toThrow(InternalServerError); + }); + + test("with unknown `feature`", () => { + const createdUser = { + features: [], + }; + + expect(() => { + authorization.can(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with unknown `user`", () => { + const createdUser = { + username: "", + }; + + expect(() => { + authorization.can(createdUser, ""); + }).toThrow(InternalServerError); + }); + + test("with unknown `user` and `feature`", () => { + const createdUser = { + username: "", + features: [], + }; + + expect(() => { + authorization.can(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with valid `user` and know `feature`", () => { + const createdUser = { + features: ["read:status"], + }; + + expect(authorization.can(createdUser, "read:status")).toBe(true); + }); + }); + + describe(".filterOutput()", () => { + test("without `user`", () => { + expect(() => { + authorization.filterOutput(); + }).toThrow(InternalServerError); + }); + + test("without `user.features`", () => { + const createdUser = { + username: "UserWithoutFeatures", + }; + + expect(() => { + authorization.filterOutput(createdUser); + }).toThrow(InternalServerError); + }); + + test("with unknown `feature`", () => { + const createdUser = { + features: [], + }; + + expect(() => { + authorization.filterOutput(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with unknown `user`", () => { + const createdUser = { + username: "", + }; + + expect(() => { + authorization.filterOutput(createdUser, ""); + }).toThrow(InternalServerError); + }); + + test("with unknown `user` and `feature`", () => { + const createdUser = { + username: "", + features: [], + }; + + expect(() => { + authorization.filterOutput(createdUser, "unknown:feature"); + }).toThrow(InternalServerError); + }); + + test("with valid `user`, know `feature` but no `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + expect(() => { + authorization.filterOutput(createdUser, "read:user"); + }).toThrow(InternalServerError); + }); + + test("with valid `user`, know `feature` and `resource`", () => { + const createdUser = { + features: ["read:user"], + }; + + const resource = { + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + update_at: "2026-0101T00:00:00.000Z", + email: "resource@gmail.com", + password: "resource", + }; + + const result = authorization.filterOutput( + createdUser, + "read:user", + resource, + ); + + expect(result).toEqual({ + id: 1, + username: "resource", + features: ["read:user"], + created_at: "2026-0101T00:00:00.000Z", + update_at: "2026-0101T00:00:00.000Z", + }); + }); + }); +});