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 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/infra/controller.js b/infra/controller.js index 426cb86..274828a 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,5 +1,7 @@ import * as cookie from "cookie"; import session from "models/session"; +import user from "models/user"; +import authorization from "models/authorization"; import { InternalServerError, @@ -7,15 +9,20 @@ import { NotFoundError, UnauthorizedError, ValidationError, + ForbiddenError, } 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) { - if (error instanceof ValidationError || error instanceof NotFoundError) { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ForbiddenError + ) { return response.status(error.statusCode).json(error); } @@ -24,17 +31,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, }); @@ -51,6 +58,50 @@ 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.user_id); + return userObject; +} + +async function getAnonymousUser() { + const anonymousUser = { + features: ["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 (authorization.can(userTryingToRequest, 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/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 65dff0b..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, }; } } @@ -116,3 +118,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/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/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/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/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/infra/webserver.js b/infra/webserver.js new file mode 100644 index 0000000..2727939 --- /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 ?? "3000"; + 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..b1a2b72 --- /dev/null +++ b/models/activation.js @@ -0,0 +1,138 @@ +import database from "infra/database"; +import email from "infra/email"; +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 + +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 findOneValidById(id) { + const newToken = await runSelectQuery(id); + return newToken; + + async function runSelectQuery(id) { + const result = await database.query({ + text: ` + SELECT + * + FROM + user_activation_tokens + WHERE + id = $1 + AND expires_at > NOW() + AND used_at IS NULL + LIMIT + 1 + `, + 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]; + } +} + +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}/registration/activate/${activationToken.id} + +Best regards, +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 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", + "update:user", + ]); + return activatedUser; +} + +const activation = { + findOneValidById, + create, + sendEmailToUser, + markAsUsed, + activateUserByUserId, + EXPIRATION_IN_MILLISECONDS, +}; +export default activation; diff --git a/models/authorization.js b/models/authorization.js new file mode 100644 index 0000000..ee7946f --- /dev/null +++ b/models/authorization.js @@ -0,0 +1,152 @@ +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)) { + 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, resource) { + validateUser(user); + validateFeature(feature); + validateResource(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; + } +} + +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, +}; + +export default authorization; 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..702d631 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,17 +90,18 @@ async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); await hashPasswordInObject(userInputValues); + injectDefaultFeaturesInObject(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 - 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) { @@ -134,10 +140,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 @@ -168,7 +174,7 @@ async function validateUniqueUsername(username) { text: ` SELECT username - FROM + FROM users WHERE LOWER(username) = LOWER($1) @@ -177,7 +183,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", }); } @@ -188,7 +194,7 @@ async function validateUniqueEmail(email) { text: ` SELECT email - FROM + FROM users WHERE LOWER(email) = LOWER($1) @@ -197,7 +203,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.", }); } @@ -208,12 +214,60 @@ 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]; + } +} + +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, findOneByUsername, findOneByEmail, update, + setFeatures, + addFeatures, }; export default user; 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..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 .", @@ -66,6 +67,9 @@ "secretlint": "9.0.0", "set-cookie-parser": "2.7.1" }, + "engines": { + "node": "24" + }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" 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..2cc731c --- /dev/null +++ b/pages/api/v1/activations/[token_id]/index.js @@ -0,0 +1,30 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import activation from "models/activation"; +import authorization from "models/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 activationTokenId = request.query.token_id; + const userTryingToPatch = request.context.user; + const validActivationToken = + await activation.findOneValidById(activationTokenId); + + await activation.activateUserByUserId(validActivationToken.user_id); + + const usedActivationToken = await activation.markAsUsed(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 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 82bc3d8..87181f8 100644 --- a/pages/api/v1/sessions/index.js +++ b/pages/api/v1/sessions/index.js @@ -2,34 +2,59 @@ 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"; -const router = createRouter(); +import { ForbiddenError } from "infra/errors"; -router.post(postHandler); +const router = createRouter(); +router.use(controller.injectAnonymousOrUser); +router.post(controller.canRequest("create:session"), postHandler); router.delete(deleteHandler); 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); - 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.exporeById(sessionObject.id); + 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 d30dba1..31a2d65 100644 --- a/pages/api/v1/user/index.js +++ b/pages/api/v1/user/index.js @@ -2,15 +2,18 @@ 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(); -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 sessionToken = request.cookies.session_id; + const userTryingToGet = request.context.user; const sessionObject = await session.findOneValidByToken(sessionToken); const renewedSessionObject = await session.renew(sessionObject.id); @@ -18,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 7ea8e64..e146eb2 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -1,26 +1,52 @@ 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(); +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 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); + 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 f93e721..39a2c95 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 "models/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 userInputValues = request.body; + const userTryingToPost = request.context.user; + const newUser = await user.create(userInputValues); - 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/test/integration/api/v1/migrations/get.test.js b/test/integration/api/v1/migrations/get.test.js deleted file mode 100644 index eb75704..0000000 --- a/test/integration/api/v1/migrations/get.test.js +++ /dev/null @@ -1,20 +0,0 @@ -import orchestrator from "test/orchestrator"; - -beforeAll(async () => { - await orchestrator.waitAllServices(); - await orchestrator.clearDatabase(); -}); - -describe("GET /api/v1/migrations", () => { - describe("Anonymos user", () => { - test("Retrieving pedding migrations", async () => { - const response = await fetch("http://localhost:3000/api/v1/migrations"); - expect(response.status).toBe(200); - - const responseBody = await response.json(); - - expect(Array.isArray(responseBody)).toBe(true); - expect(responseBody.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/test/integration/api/v1/migrations/post.test.js b/test/integration/api/v1/migrations/post.test.js deleted file mode 100644 index a758e21..0000000 --- a/test/integration/api/v1/migrations/post.test.js +++ /dev/null @@ -1,42 +0,0 @@ -import orchestrator from "test/orchestrator"; - -beforeAll(async () => { - await orchestrator.waitAllServices(); - await orchestrator.clearDatabase(); -}); - -describe("POST /api/v1/migrations", () => { - describe("Anonymos user", () => { - describe("Running pedding migrations", () => { - test("Running for first time", async () => { - const response1 = await fetch( - "http://localhost:3000/api/v1/migrations", - { - method: "POST", - }, - ); - - expect(response1.status).toBe(201); - - const response1Body = await response1.json(); - - expect(Array.isArray(response1Body)).toBe(true); - expect(response1Body.length).toBeGreaterThan(0); - }); - test("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); - }); - }); - }); -}); diff --git a/test/integration/api/v1/status/get.test.js b/test/integration/api/v1/status/get.test.js deleted file mode 100644 index dd2dc10..0000000 --- a/test/integration/api/v1/status/get.test.js +++ /dev/null @@ -1,31 +0,0 @@ -import orchestrator from "test/orchestrator"; - -beforeAll(async () => { - await orchestrator.waitAllServices(); -}); - -describe("GET /api/v1/status", () => { - describe("Anonymos user", () => { - 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(); - 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(); - expect(responseBody.dependencies.database.version).toBe("16.0"); - 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); - }); - }); -}); 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..c420bca --- /dev/null +++ b/tests/integration/_use-cases/registration-flow.test.js @@ -0,0 +1,126 @@ +import webserver from "infra/webserver"; +import activation from "models/activation"; +import user from "models/user"; +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)", () => { + let createUserResponseBody; + let activationToken; + let createSessionResponseBody; + 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); + + createUserResponseBody = await createUserResponse.json(); + expect(createUserResponseBody).toEqual({ + id: createUserResponseBody.id, + username: "RegistrationFlow", + features: ["read:activation_token"], + created_at: createUserResponseBody.created_at, + updated_at: createUserResponseBody.updated_at, + }); + }); + + test("Receive activation email", async () => { + const lastEmail = await orchestrator.getLastEmail(); + + expect(lastEmail.sender).toBe(""); + expect(lastEmail.recipients[0]).toBe(""); + expect(lastEmail.subject).toBe("Activate you account at InSystem"); + expect(lastEmail.text).toContain("RegistrationFlow"); + + 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("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", + "read:session", + "update:user", + ]); + }); + + test("Login", async () => { + const createSessionResponse = await fetch( + "http://localhost:3000/api/v1/sessions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "registration.flow@test.com", + password: "senha123", + }), + }, + ); + + expect(createSessionResponse.status).toBe(201); + + createSessionResponseBody = await createSessionResponse.json(); + + expect(createSessionResponseBody.user_id).toEqual( + createUserResponseBody.id, + ); + }); + + 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/activations/[token_id]/patch.test.js b/tests/integration/api/v1/activations/[token_id]/patch.test.js new file mode 100644 index 0000000..89375ec --- /dev/null +++ b/tests/integration/api/v1/activations/[token_id]/patch.test.js @@ -0,0 +1,196 @@ +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", + "update:user", + ]); + }); + + 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); + + 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, + }); + }); + }); +}); diff --git a/test/integration/api/v1/migrations/delete.test.js b/tests/integration/api/v1/migrations/delete.test.js similarity index 86% rename from test/integration/api/v1/migrations/delete.test.js rename to tests/integration/api/v1/migrations/delete.test.js index 1507b68..6c84ded 100644 --- a/test/integration/api/v1/migrations/delete.test.js +++ b/tests/integration/api/v1/migrations/delete.test.js @@ -1,12 +1,12 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); }); 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 new file mode 100644 index 0000000..b640f66 --- /dev/null +++ b/tests/integration/api/v1/migrations/get.test.js @@ -0,0 +1,64 @@ +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); + }); + }); +}); diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js new file mode 100644 index 0000000..b4a9a98 --- /dev/null +++ b/tests/integration/api/v1/migrations/post.test.js @@ -0,0 +1,68 @@ +import orchestrator from "tests/orchestrator"; + +beforeAll(async () => { + await orchestrator.waitAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("POST /api/v1/migrations", () => { + describe("Anonymous user", () => { + test("Running pending migrations", async () => { + const response = await fetch("http://localhost:3000/api/v1/migrations", { + method: "POST", + }); + + expect(response.status).toBe(403); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ForbiddenError", + message: "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}`, + }, + }); + + 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 create:migration.", + status_code: 403, + }); + }); + }); + 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/test/integration/api/v1/sessions/delete.test.js b/tests/integration/api/v1/sessions/delete.test.js similarity index 94% rename from test/integration/api/v1/sessions/delete.test.js rename to tests/integration/api/v1/sessions/delete.test.js index c8090a7..2733034 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 () => { @@ -35,13 +35,13 @@ 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({ 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,19 +65,18 @@ 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: { Cookie: `session_id=${sessionObject.token}`, }, }); - 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/test/integration/api/v1/sessions/post.test.js b/tests/integration/api/v1/sessions/post.test.js similarity index 94% rename from test/integration/api/v1/sessions/post.test.js rename to tests/integration/api/v1/sessions/post.test.js index 5b63690..12a003a 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 () => { @@ -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", @@ -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", }); @@ -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 new file mode 100644 index 0000000..056bbb9 --- /dev/null +++ b/tests/integration/api/v1/status/get.test.js @@ -0,0 +1,90 @@ +import orchestrator from "tests/orchestrator"; + +beforeAll(async () => { + await orchestrator.waitAllServices(); +}); + +describe("GET /api/v1/status", () => { + describe("Anonymous user", () => { + 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(); + 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("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(); + expect(responseBody.dependencies.database.version).toBe("16.0"); + 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); + }); + }); +}); diff --git a/test/integration/api/v1/status/post.test.js b/tests/integration/api/v1/status/post.test.js similarity index 91% rename from test/integration/api/v1/status/post.test.js rename to tests/integration/api/v1/status/post.test.js index 4178ae2..c6f3037 100644 --- a/test/integration/api/v1/status/post.test.js +++ b/tests/integration/api/v1/status/post.test.js @@ -1,11 +1,11 @@ -import orchestrator from "test/orchestrator"; +import orchestrator from "tests/orchestrator"; beforeAll(async () => { await orchestrator.waitAllServices(); }); 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/test/integration/api/v1/user/get.test.js b/tests/integration/api/v1/user/get.test.js similarity index 83% rename from test/integration/api/v1/user/get.test.js rename to tests/integration/api/v1/user/get.test.js index 6246404..040e054 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 () => { @@ -11,30 +11,46 @@ 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); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch(`http://localhost:3000/api/v1/user`, { headers: { Cookie: `session_id=${sessionObject.token}`, }, }); - const responseBody = await response.json(); - const caheControl = response.headers.get("Cache-Control"); - expect(response.status).toBe(200); - expect(caheControl).toBe( + + const responseBody = await response.json(); + const cacheControl = response.headers.get("Cache-Control"); + expect(cacheControl).toBe( "no-store, no-cache, max-age=0, must-revalidate", ); expect(responseBody).toEqual({ id: createdUser.id, username: "UserWithValidSession", email: createdUser.email, - password: createdUser.password, + features: createdUser.features, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), }); @@ -59,7 +75,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 +83,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, }); }); @@ -82,8 +98,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.", @@ -105,13 +123,13 @@ 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({ username: "UserWithExpiredSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); jest.useRealTimers(); @@ -121,8 +139,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.", @@ -144,13 +164,13 @@ 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({ + const createdUser = await orchestrator.createActivatedUser({ username: "UserWithAboutToExpireSession", }); - const sessionObject = await orchestrator.createSession(createdUser.id); + const sessionObject = await orchestrator.createSession(createdUser); jest.useRealTimers(); @@ -160,14 +180,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, - password: createdUser.password, + features: createdUser.features, created_at: createdUser.created_at.toISOString(), updated_at: createdUser.updated_at.toISOString(), }); @@ -192,7 +213,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 +221,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/test/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js similarity index 68% rename from test/integration/api/v1/users/[username]/get.test.js rename to tests/integration/api/v1/users/[username]/get.test.js index 9dfae3e..da79292 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(); @@ -8,26 +8,25 @@ 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/${createdUser.username}`, ); const responseBody = await response.json(); expect(response.status).toBe(200); expect(responseBody).toEqual({ - id: responseBody.id, - username: "MesmoCase", - email: createdUser.email, - 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(); @@ -36,30 +35,29 @@ 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(); expect(response.status).toBe(200); expect(responseBody).toEqual({ - id: responseBody.id, - username: "CaseDiferente", - email: createdUser.email, - 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(); 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/test/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js similarity index 50% rename from test/integration/api/v1/users/[username]/patch.test.js rename to tests/integration/api/v1/users/[username]/patch.test.js index f51f5d8..18a2bef 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"; @@ -10,12 +10,47 @@ beforeAll(async () => { }); describe("PATCH /api/v1/users/[username]", () => { - describe("Anonymos user", () => { + 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); + 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); 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", @@ -57,20 +92,51 @@ 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, }); }); + test("With `user2` targeting `user1`", async () => { + const createsUser1 = await orchestrator.createUser(); + const createdUser2 = await orchestrator.createActivatedUser(); + const sessionObject2 = await orchestrator.createSession(createdUser2); + + 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", }); - const createdUser2 = await orchestrator.createUser({ + const createdUser2 = await orchestrator.createActivatedUser({ email: "email2@test.com", }); + const sessionObject2 = await orchestrator.createSession(createdUser2); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser2.username}`, @@ -78,6 +144,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", @@ -90,14 +157,15 @@ 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, }); }); test("With unique 'username'", async () => { - const createdUser = await orchestrator.createUser(); + const createdUser = await orchestrator.createActivatedUser(); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -105,6 +173,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ username: "uniqueUser", @@ -116,11 +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, - 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); @@ -130,7 +198,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); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -138,6 +207,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", @@ -149,23 +219,28 @@ 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", - 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 () => { - const createdUser = await orchestrator.createUser({ + const createdUser = await orchestrator.createActivatedUser({ password: "oldPassword", }); + const sessionObject = await orchestrator.createSession(createdUser); const response = await fetch( `http://localhost:3000/api/v1/users/${createdUser.username}`, @@ -173,6 +248,7 @@ describe("PATCH /api/v1/users/[username]", () => { method: "PATCH", headers: { "Content-Type": "application/json", + Cookie: `session_id=${sessionObject.token}`, }, body: JSON.stringify({ password: "newPassword", @@ -184,11 +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, - 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); @@ -196,18 +271,63 @@ 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); }); }); + + 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); + + 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: defaultUser.id, + username: "user3", + features: defaultUser.features, + 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/test/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js similarity index 70% rename from test/integration/api/v1/users/post.test.js rename to tests/integration/api/v1/users/post.test.js index 71ff1f5..08ffa31 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"; @@ -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: { @@ -30,8 +30,7 @@ describe("POST /api/v1/users", () => { expect(responseBody).toEqual({ id: responseBody.id, username: "isaacisrael", - email: "isaac@test.com", - password: responseBody.password, + features: ["read:activation_token"], created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); @@ -75,7 +74,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,10 +102,40 @@ 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, }); }); }); + + describe("Default user", () => { + test("With unique and valid data", async () => { + const user1 = await orchestrator.createActivatedUser(); + const user1SessionObject = await orchestrator.createSession(user1); + + 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, + }); + }); + }); }); diff --git a/test/integration/infra/email.test.js b/tests/integration/infra/email.test.js similarity index 75% rename from test/integration/infra/email.test.js rename to tests/integration/infra/email.test.js index ac72d65..bfb6c52 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(); @@ -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"); diff --git a/test/orchestrator.js b/tests/orchestrator.js similarity index 72% rename from test/orchestrator.js rename to tests/orchestrator.js index 0c8ced7..2ee81ff 100644 --- a/test/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,8 +58,22 @@ async function createUser(userObject) { }); } -async function createSession(userId) { - return session.create(userId); +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(userObject) { + return session.create(userObject.id); +} + +async function addFeaturesToUser(userObject, features) { + const updatedUser = await user.addFeatures(userObject.id, features); + return updatedUser; } async function deleteAllEmails() { @@ -70,6 +85,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`, ); @@ -79,6 +98,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, @@ -87,6 +113,10 @@ const orchestrator = { createSession, deleteAllEmails, getLastEmail, + extractUUID, + activateUser, + createActivatedUser, + 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..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, + }); + }); + }); +});