diff --git a/.env.sample b/.env.sample index c1ae101..49969ce 100644 --- a/.env.sample +++ b/.env.sample @@ -9,6 +9,12 @@ RATE_LIMIT_ENABLED=false RATE_LIMIT_WINDOW_MS=1000 RATE_LIMIT_MAX_REQUESTS=10 +JWT_SECRET=ABCD23DCAA +JWT_EXPIRATION_TIME=10H + +INITIAL_USER_USERNAME= +INITIAL_USER_PASSWORD= + DB=foobar.db START_WITH_STUDIO=false @@ -16,6 +22,3 @@ START_WITH_STUDIO=false TOKEN_SECRET=ABCD23DCAA ACCESS_TOKEN_EXPIRATION_TIME=10H REFRESH_TOKEN_EXPIRATION_TIME=2D - -INITIAL_USER_USERNAME=superuser -INITIAL_USER_PASSWORD=hello@3245CD$ diff --git a/README.md b/README.md index 40d911a..f79f578 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,13 @@ Options: -r, --rate-limit-enabled Enable rate limiting [boolean] -c, --cors CORS whitelist origins [string] -a, --auth Enable authentication and authorization [boolean] - -ats, --accesstokensecret Access Token Secret [string] + + -iuu, --initialuserusername Initial user username [string] + -iup, --initialuserpassword Initial user password [string] + + -ats, --accesstokensecret Access Token Secret [string] -atet, --accesstokenexpirationtime Access Token Expiration Time [string] - -rts, --refreshtokensecret Refresh Token Secret [string] + -rts, --refreshtokensecret Refresh Token Secret [string] -rtet, --refreshtokenexpirationtime Refresh Token Expiration Time [string] -S, --studio Start Soul Studio in parallel --help Show help @@ -58,18 +62,23 @@ To run Soul in auth mode, allowing login and signup features with authorization Run the Soul command with the necessary parameters: -``` -soul --d foobar.db -a -ts -atet=4H -rtet=3D -``` + + ``` + + soul --d foobar.db -a -ts -atet=4H -rtet=3D -iuu=john -iup= + + ``` Note: When configuring your JWT Secret, it is recommended to use a long string value for enhanced security. It is advisable to use a secret that is at least 10 characters in length. In this example: The `-a` flag instructs Soul to run in auth mode. -The `-ts` flag allows you to pass a JWT secret value for the `access and refresh tokens` generation and verification. Replace with your desired secret value. +The `-ts` flag allows you to pass a JWT secret value for the `access and refresh tokens` generation and verification. Replace with your desired secret value. The `-atet` flag sets the JWT expiration time for the access token. In this case, it is set to four hours (4H), meaning the token will expire after 4 hours. -Teh `-rtet` flag sets the JWT expiration time for the refresh token. In this case, it is set to three days (3D), meaning the token will expire after 3 days. +The `-rtet` flag sets the JWT expiration time for the refresh token. In this case, it is set to three days (3D), meaning the token will expire after 3 days. +The `-iuu` flag is used to pass a username for the initial user +The `-iup` flag is used to pass a password for the initial user Here are some example values for the `-atet` and `rtet` flags @@ -129,6 +138,14 @@ npm install # Install dependencies npm run dev # Start the dev server ``` +## Testing + +Set the `AUTH` variable to `true` in your `.env` file and use the command below to run the tests + +``` + npm run test +``` + ## Community [Join](https://bit.ly/soul-discord) the discussion in our Discord server and help making Soul together. diff --git a/src/cli.js b/src/cli.js index 6072c3a..d18d773 100644 --- a/src/cli.js +++ b/src/cli.js @@ -74,6 +74,18 @@ if (process.env.NO_CLI !== 'true') { default: '3D', demandOption: false, }) + .options('iuu', { + alias: 'initialuserusername', + describe: 'Initial superuser username', + type: 'string', + demandOption: false, + }) + .options('iup', { + alias: 'initialuserpassword', + describe: 'Initial superuser password', + type: 'string', + demandOption: false, + }) .options('S', { alias: 'studio', describe: 'Start Soul Studio in parallel', diff --git a/src/config/index.js b/src/config/index.js index b92c2ea..617e403 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -30,6 +30,9 @@ const envVarsSchema = Joi.object() START_WITH_STUDIO: Joi.boolean().default(false), + INITIAL_USER_USERNAME: Joi.string(), + INITIAL_USER_PASSWORD: Joi.string(), + TOKEN_SECRET: Joi.string().default(null), ACCESS_TOKEN_EXPIRATION_TIME: Joi.string().default('5H'), REFRESH_TOKEN_EXPIRATION_TIME: Joi.string().default('3D'), @@ -76,6 +79,14 @@ if (argv.refreshtokenexpirationtime) { env.REFRESH_TOKEN_EXPIRATION_TIME = argv.refreshtokenexpirationtime; } +if (argv.initialuserusername) { + env.INITIAL_USER_USERNAME = argv.initialuserusername; +} + +if (argv.initialuserpassword) { + env.INITIAL_USER_PASSWORD = argv.initialuserpassword; +} + const { value: envVars, error } = envVarsSchema .prefs({ errors: { label: 'key' } }) .validate(env); @@ -109,6 +120,11 @@ module.exports = { refreshTokenExpirationTime: argv.refreshtokenexpirationtime || envVars.REFRESH_TOKEN_EXPIRATION_TIME, + initialUserUsername: + argv.initialuserusername || envVars.INITIAL_USER_USERNAME, + initialUserPassword: + argv.initialuserpassword || envVars.INITIAL_USER_PASSWORD, + rateLimit: { enabled: argv['rate-limit-enabled'] || envVars.RATE_LIMIT_ENABLED, windowMs: envVars.RATE_LIMIT_WINDOW, diff --git a/src/constants/api.js b/src/constants/api.js index 68cfac2..799ee77 100644 --- a/src/constants/api.js +++ b/src/constants/api.js @@ -1,5 +1,13 @@ module.exports = { defaultRoutes: ['_users', '_roles', '_roles_permissions', '_users_roles'], + baseTableUrl: '/api/tables', + fields: { + _users: { + SALT: 'salt', + IS_SUPERUSER: 'is_superuser', + HASHED_PASSWORD: 'hashed_password', + }, + }, DEFAULT_PAGE_LIMIT: 10, DEFAULT_PAGE_INDEX: 0, PASSWORD: { diff --git a/src/constants/index.js b/src/constants/index.js index 9880ded..4f1d339 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -1,5 +1,5 @@ -const dbTables = require('./dbTables'); +const dbConstants = require('./tables'); const apiConstants = require('./api'); const constantRoles = require('./roles'); -module.exports = { dbTables, apiConstants, constantRoles }; +module.exports = { dbConstants, apiConstants, constantRoles }; diff --git a/src/constants/tables.js b/src/constants/tables.js new file mode 100644 index 0000000..631af6c --- /dev/null +++ b/src/constants/tables.js @@ -0,0 +1,13 @@ +module.exports = { + reservedTableNames: [ + '_users', + '_roles', + '_roles_permissions', + '_users_roles', + ], + + USER_TABLE: '_users', + ROLE_TABLE: '_roles', + USER_ROLES_TABLE: '_users_roles', + ROLE_PERMISSIONS_TABLE: '_roles_permissions', +}; diff --git a/src/controllers/auth.js b/src/controllers/auth.js index 3ae7cd1..40806c6 100644 --- a/src/controllers/auth.js +++ b/src/controllers/auth.js @@ -1,4 +1,6 @@ const { tableService, rowService } = require('../services'); +const { constantRoles, apiConstants, dbConstants } = require('../constants'); +const schema = require('../db/schema'); const config = require('../config'); const { hashPassword, @@ -6,40 +8,53 @@ const { comparePasswords, generateToken, decodeToken, + toBoolean, } = require('../utils'); -const { dbTables, constantRoles, apiConstants } = require('../constants'); +const { USER_TABLE, ROLE_TABLE, USERS_ROLES_TABLE, ROLE_PERMISSIONS_TABLE } = + dbConstants; const createDefaultTables = async () => { let roleId; // check if the default tables are already created - const roleTable = tableService.checkTableExists('_roles'); - const usersTable = tableService.checkTableExists('_users'); - const rolesPermissionTable = - tableService.checkTableExists('_roles_permissions'); - const usersRolesTable = tableService.checkTableExists('_users_roles'); + const roleTable = tableService.checkTableExists(ROLE_TABLE); + const usersTable = tableService.checkTableExists(USER_TABLE); + const rolesPermissionTable = tableService.checkTableExists( + ROLE_PERMISSIONS_TABLE, + ); + const usersRolesTable = tableService.checkTableExists(USERS_ROLES_TABLE); // create _users table if (!usersTable) { // create the _users table - tableService.createTable('_users', dbTables.userSchema); + tableService.createTable(USER_TABLE, schema.userSchema); } // create _users_roles table if (!usersRolesTable) { // create the _users_roles table - tableService.createTable('_users_roles', dbTables.usersRoleSchema); + tableService.createTable( + USERS_ROLES_TABLE, + + schema.usersRoleSchema, + { + multipleUniqueConstraints: { + name: 'unique_users_role', + fields: ['user_id', 'role_id'], + }, + }, + ); } // create _roles table if (!roleTable) { // create the _role table - tableService.createTable('_roles', dbTables.roleSchema); + tableService.createTable(ROLE_TABLE, schema.roleSchema); // create a default role in the _roles table const role = rowService.save({ - tableName: '_roles', + tableName: ROLE_TABLE, fields: { name: constantRoles.DEFAULT_ROLE }, }); roleId = role.lastInsertRowid; @@ -49,8 +64,8 @@ const createDefaultTables = async () => { if (!rolesPermissionTable && roleId) { // create the _roles_permissions table tableService.createTable( - '_roles_permissions', - dbTables.rolePermissionSchema, + ROLE_PERMISSIONS_TABLE, + schema.rolePermissionSchema, { multipleUniqueConstraints: { name: 'unique_role_table', @@ -77,7 +92,7 @@ const createDefaultTables = async () => { // store the permissions in the db rowService.bulkWrite({ - tableName: '_roles_permissions', + tableName: ROLE_PERMISSIONS_TABLE, fields: permissions, }); } @@ -91,7 +106,7 @@ const updateSuperuser = async (fields) => { try { // find the user by using the id field const users = rowService.get({ - tableName: '_users', + tableName: USER_TABLE, whereString: 'WHERE id=?', whereStringValues: [id], }); @@ -131,7 +146,7 @@ const updateSuperuser = async (fields) => { // update the user rowService.update({ - tableName: '_users', + tableName: USER_TABLE, lookupField: `id`, fieldsString, pks: `${id}`, @@ -147,18 +162,48 @@ const updateSuperuser = async (fields) => { }; const registerUser = async (req, res) => { + /* + #swagger.tags = ['Auth'] + #swagger.summary = 'Register User' + #swagger.description = 'Endpoint to signup' + + #swagger.parameters['username'] = { + in: 'body', + required: true, + type: 'object', + schema: { $ref: '#/definitions/UserRegistrationRequestBody' } + } + */ + const { username, password } = req.body.fields; try { + if (!username) { + return res.status(400).send({ message: 'username is required' }); + } + + if (!password) { + return res.status(400).send({ message: 'password is required' }); + } + // check if the username is taken let user = rowService.get({ - tableName: '_users', + tableName: USER_TABLE, whereString: 'WHERE username=?', whereStringValues: [username], }); if (user.length > 0) { return res.status(409).send({ message: 'This username is taken' }); + + /* + #swagger.responses[409] = { + description: 'Username taken error', + schema: { + $ref: '#/definitions/UsernameTakenErrorResponse' + } + } + */ } // check if the password is weak @@ -170,6 +215,15 @@ const registerUser = async (req, res) => { return res.status(400).send({ message: 'This password is weak, please use another password', }); + + /* + #swagger.responses[400] = { + description: 'Weak password error', + schema: { + $ref: '#/definitions/WeakPasswordErrorResponse' + } + } + */ } // hash the password @@ -177,7 +231,7 @@ const registerUser = async (req, res) => { // create the user const newUser = rowService.save({ - tableName: '_users', + tableName: USER_TABLE, fields: { username, salt, @@ -188,7 +242,7 @@ const registerUser = async (req, res) => { // find the default role from the DB let defaultRole = rowService.get({ - tableName: '_roles', + tableName: ROLE_TABLE, whereString: 'WHERE name=?', whereStringValues: [constantRoles.DEFAULT_ROLE], }); @@ -197,15 +251,32 @@ const registerUser = async (req, res) => { return res.status(500).send({ message: 'Please restart soul so a default role can be created', }); + /* + #swagger.responses[500] = { + description: 'Server error', + schema: { + $ref: '#/definitions/DefaultRoleNotCreatedErrorResponse' + } + } + */ } // create a role for the user rowService.save({ - tableName: '_users_roles', + tableName: USERS_ROLES_TABLE, fields: { user_id: newUser.lastInsertRowid, role_id: defaultRole[0].id }, }); res.status(201).send({ message: 'Row Inserted' }); + + /* + #swagger.responses[201] = { + description: 'Row inserted', + schema: { + $ref: '#/definitions/InsertRowSuccessResponse' + } + } + */ } catch (error) { console.log(error); res.status(500).send({ message: error.message }); @@ -213,13 +284,26 @@ const registerUser = async (req, res) => { }; const obtainAccessToken = async (req, res) => { + /* + #swagger.tags = ['Auth'] + #swagger.summary = 'Obtain Access Token' + #swagger.description = 'Endpoint to generate access and refresh tokens' + + #swagger.parameters['body'] = { + in: 'body', + required: true, + type: 'object', + schema: { $ref: '#/definitions/ObtainAccessTokenRequestBody' } + } + */ + // extract payload const { username, password } = req.body.fields; try { // check if the username exists in the Db const users = rowService.get({ - tableName: '_users', + tableName: USER_TABLE, whereString: 'WHERE username=?', whereStringValues: [username], }); @@ -234,29 +318,47 @@ const obtainAccessToken = async (req, res) => { if (!isMatch) { return res.status(401).send({ message: 'Invalid username or password' }); + /* + #swagger.responses[401] = { + description: 'Invalid username or password error', + schema: { + $ref: '#/definitions/InvalidCredentialErrorResponse' + } + } + */ } - // get the users role from the DB - const usersRole = rowService.get({ - tableName: '_users_roles', - whereString: 'WHERE user_id=?', - whereStringValues: [user.id], - }); + let userRoles, permissions, roleIds; - const roleId = usersRole[0].role_id; + // if the user is not a superuser get the role and its permission from the DB + if (!toBoolean(user.is_superuser)) { + userRoles = rowService.get({ + tableName: USERS_ROLES_TABLE, + whereString: 'WHERE user_id=?', + whereStringValues: [user.id], + }); - // get the permission of the role - const permissions = rowService.get({ - tableName: '_roles_permissions', - whereString: 'WHERE role_id=?', - whereStringValues: [roleId], - }); + if (userRoles <= 0) { + return res + .status(404) + .send({ message: 'Role not found for this user' }); + } + + roleIds = userRoles.map((role) => role.role_id); + + // get the permission of the role + permissions = rowService.get({ + tableName: ROLE_PERMISSIONS_TABLE, + whereString: `WHERE role_id IN (${roleIds.map(() => '?')})`, + whereStringValues: [...roleIds], + }); + } const payload = { username: user.username, userId: user.id, isSuperuser: user.is_superuser, - roleId, + roleIds, permissions, }; @@ -280,6 +382,15 @@ const obtainAccessToken = async (req, res) => { res.cookie('refreshToken', refreshToken, cookieOptions); res.status(201).send({ message: 'Success', data: { userId: user.id } }); + + /* + #swagger.responses[201] = { + description: 'Access token and Refresh token generated', + schema: { + $ref: '#/definitions/ObtainAccessTokenSuccessResponse' + } + } + */ } catch (error) { console.log(error); return res.status(500).json({ @@ -290,6 +401,12 @@ const obtainAccessToken = async (req, res) => { }; const refreshAccessToken = async (req, res) => { + /* + #swagger.tags = ['Auth'] + #swagger.summary = 'Refresh Access Token' + #swagger.description = 'Endpoint to refresh access and refresh tokens' + */ + try { // extract the payload from the token and verify it const payload = await decodeToken( @@ -299,7 +416,7 @@ const refreshAccessToken = async (req, res) => { // find the user const users = rowService.get({ - tableName: '_users', + tableName: USER_TABLE, whereString: 'WHERE id=?', whereStringValues: [payload.userId], }); @@ -308,25 +425,56 @@ const refreshAccessToken = async (req, res) => { return res .status(401) .send({ message: `User with userId = ${payload.userId} not found` }); + + /* + #swagger.responses[401] = { + description: 'User not found error', + schema: { + $ref: '#/definitions/UserNotFoundErrorResponse' + } + } + */ } + let userRoles, permissions, roleIds; const user = users[0]; - const newPaylod = { - username: payload.username, - userId: payload.userId, - roleId: payload.roleId, + // if the user is not a superuser get the role and its permission from the DB + if (!toBoolean(user.is_superuser)) { + userRoles = rowService.get({ + tableName: USERS_ROLES_TABLE, + whereString: 'WHERE user_id=?', + whereStringValues: [user.id], + }); + + roleIds = userRoles.map((role) => role.role_id); + + // get the permission of the role + permissions = rowService.get({ + tableName: ROLE_PERMISSIONS_TABLE, + whereString: `WHERE role_id IN (${roleIds.map(() => '?')})`, + whereStringValues: [...roleIds], + }); + } + + const newPayload = { + username: user.username, + userId: user.id, + isSuperuser: user.is_superuser, + roleIds, + permissions, }; + // generate an access token const accessToken = await generateToken( - { subject: 'accessToken', ...newPaylod }, + { subject: 'accessToken', ...newPayload }, config.tokenSecret, config.accessTokenExpirationTime, ); // generate a refresh token const refreshToken = await generateToken( - { subject: 'refreshToken', ...newPaylod }, + { subject: 'refreshToken', ...newPayload }, config.tokenSecret, config.refreshTokenExpirationTime, ); @@ -336,20 +484,52 @@ const refreshAccessToken = async (req, res) => { res.cookie('accessToken', accessToken, cookieOptions); res.cookie('refreshToken', refreshToken, cookieOptions); - res.status(201).send({ message: 'Success', data: { userId: user.id } }); + res.status(200).send({ message: 'Success', data: { userId: user.id } }); + + /* + #swagger.responses[200] = { + description: 'Access token refreshed', + schema: { + $ref: '#/definitions/RefreshAccessTokenSuccessResponse' + } + } + */ } catch (error) { - res.status(401).send({ message: 'Invalid refresh token' }); + res.status(403).send({ message: 'Invalid refresh token' }); + /* + #swagger.responses[401] = { + description: 'Invalid refresh token error', + schema: { + $ref: '#/definitions/InvalidRefreshTokenErrorResponse' + } + } + */ } }; const changePassword = async (req, res) => { + /* + #swagger.tags = ['Auth'] + #swagger.summary = 'Change Password' + #swagger.description = 'Endpoint to change a password' + + #swagger.parameters['body'] = { + in: 'body', + required: true, + type: 'object', + schema: { + $ref: '#/definitions/ChangePasswordRequestBody' + } + } + */ + const userInfo = req.user; const { currentPassword, newPassword } = req.body.fields; try { // get the user from the Db const users = rowService.get({ - tableName: '_users', + tableName: USER_TABLE, whereString: 'WHERE id=?', whereStringValues: [userInfo.userId], }); @@ -368,6 +548,14 @@ const changePassword = async (req, res) => { if (!isMatch) { return res.status(401).send({ message: 'Invalid current password' }); + /* + #swagger.responses[401] = { + description: 'User not found error', + schema: { + $ref: '#/definitions/InvalidPasswordErrorResponse' + } + } + */ } // check if the new password is strong @@ -379,6 +567,15 @@ const changePassword = async (req, res) => { return res.status(400).send({ message: 'This password is weak, please use another password', }); + + /* + #swagger.responses[400] = { + description: 'Weak password error', + schema: { + $ref: '#/definitions/WeakPasswordErrorResponse' + } + } + */ } // hash the password @@ -389,21 +586,119 @@ const changePassword = async (req, res) => { // update the user rowService.update({ - tableName: '_users', + tableName: USER_TABLE, lookupField: `id`, fieldsString: `hashed_password = '${hashedPassword}', salt = '${salt}'`, pks: `${user.id}`, }); - res.status(201).send({ + res.status(200).send({ message: 'Password updated successfully', data: { id: user.id, username: user.username }, }); + + /* + #swagger.responses[200] = { + description: 'Weak password error', + schema: { + $ref: '#/definitions/ChangePasswordSuccessResponse' + } + } + */ } catch (error) { res.status(500).send({ message: error.message }); } }; +const createInitialUser = async () => { + // extract some fields from the environment variables or from the CLI + const { initialUserUsername: username, initialUserPassword: password } = + config; + + try { + // check if there is are users in the DB + const users = rowService.get({ + tableName: USER_TABLE, + whereString: '', + whereStringValues: [], + }); + + if (users.length <= 0) { + // check if initial users username is passed from the env or CLI + if (!username) { + console.error( + 'Error: You should pass the initial users username either from the CLI with the --iuu or from the environment variable using the INITIAL_USER_USERNAME flag', + ); + process.exit(1); + } + + // check if initial users password is passed from the env or CLI + if (!password) { + console.error( + 'Error: You should pass the initial users password either from the CLI with the --iup or from the environment variable using the INITIAL_USER_PASSWORD flag', + ); + process.exit(1); + } + + // check if the usernmae is taken + const users = rowService.get({ + tableName: USER_TABLE, + whereString: 'WHERE username=?', + whereStringValues: [username], + }); + + if (users.length > 0) { + console.error( + 'Error: The username you passed for the initial user is already taken, please use another username', + ); + process.exit(1); + } + + // check if the password is strong + if ( + [apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes( + checkPasswordStrength(password), + ) + ) { + console.error( + 'Error: The password you passed for the initial user is weak, please use another password', + ); + process.exit(1); + } + + // hash the password + const { hashedPassword, salt } = await hashPassword(password, 10); + + // create the initial user + rowService.save({ + tableName: USER_TABLE, + fields: { + username, + hashed_password: hashedPassword, + salt, + is_superuser: 'false', + }, + }); + + console.log('Initial user created'); + } else { + console.log('Initial user is already created'); + } + } catch (error) { + console.log(error); + } +}; + +const isUsernameTaken = (username) => { + let user = rowService.get({ + tableName: USER_TABLE, + whereString: 'WHERE username=?', + whereStringValues: [username], + }); + + return user.length > 0; +}; + module.exports = { createDefaultTables, updateSuperuser, @@ -411,4 +706,6 @@ module.exports = { obtainAccessToken, refreshAccessToken, changePassword, + createInitialUser, + isUsernameTaken, }; diff --git a/src/controllers/auth.test.js b/src/controllers/auth.test.js new file mode 100644 index 0000000..8140c83 --- /dev/null +++ b/src/controllers/auth.test.js @@ -0,0 +1,313 @@ +const supertest = require('supertest'); + +const app = require('../index'); +const config = require('../config'); +const { generateToken } = require('../utils'); +const { testData } = require('../tests/testData'); + +const requestWithSupertest = supertest(app); + +describe('Auth Endpoints', () => { + describe('User Endpoints', () => { + it('POST /tables/_users/rows should register a user', async () => { + const accessToken = await generateToken( + { username: 'John', userId: 1, isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .post('/api/tables/_users/rows') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + username: testData.users.user1.username, + password: testData.strongPassword, + }, + }); + + expect(res.status).toEqual(201); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Row Inserted'); + }); + + it('POST /tables/_users/rows should throw 400 error if username is not passed', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .post('/api/tables/_users/rows') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { password: testData.strongPassword }, + }); + + expect(res.status).toEqual(400); + expect(res.body.message).toBe('username is required'); + }); + + it('POST /tables/_users/rows should throw 400 error if the password is not strong', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .post('/api/tables/_users/rows') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + username: testData.users.user2.username, + password: testData.weakPassword, + }, + }); + + expect(res.status).toEqual(400); + expect(res.body.message).toBe( + 'This password is weak, please use another password', + ); + }); + + it('POST /tables/_users/rows should throw 409 error if the username is taken', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .post('/api/tables/_users/rows') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + username: testData.users.user1.username, + password: testData.strongPassword, + }, + }); + + expect(res.status).toEqual(409); + expect(res.body.message).toBe('This username is taken'); + }); + + it('GET /tables/_users/rows should return list of users', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .get('/api/tables/_users/rows') + .set('Cookie', [`accessToken=${accessToken}`]); + + expect(res.status).toEqual(200); + expect(res.body.data[0]).toHaveProperty('id'); + expect(res.body.data[0]).toHaveProperty('username'); + expect(res.body.data[0]).toHaveProperty('is_superuser'); + expect(res.body.data[0]).toHaveProperty('createdAt'); + }); + + it('GET /tables/_users/rows/:id should retrive a single user', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .get('/api/tables/_users/rows/1') + .set('Cookie', [`accessToken=${accessToken}`]); + + expect(res.status).toEqual(200); + expect(res.body.data[0]).toHaveProperty('id'); + expect(res.body.data[0]).toHaveProperty('username'); + expect(res.body.data[0]).toHaveProperty('is_superuser'); + expect(res.body.data[0]).toHaveProperty('createdAt'); + }); + + it('PUT /tables/_users/rows/:id should update a user', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .put('/api/tables/_users/rows/1') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + username: testData.users.user3.username, + }, + }); + + expect(res.status).toEqual(200); + }); + + it('PUT /tables/_users/rows/:id should throw a 409 error if the username is taken', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .put('/api/tables/_users/rows/1') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + username: testData.users.user1.username, //A user with user1.username is already created in the first test suite + }, + }); + + expect(res.status).toEqual(409); + expect(res.body.message).toEqual('This username is already taken'); + }); + + it('DELETE /tables/_users/rows/:id should remove a user', async () => { + const accessToken = await generateToken( + { username: 'John', isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .delete('/api/tables/_users/rows/2') + .set('Cookie', [`accessToken=${accessToken}`]); + + expect(res.status).toEqual(400); + expect(res.body.message).toBe('FOREIGN KEY constraint failed'); + }); + }); + + describe('Obtain Access Token Endpoint', () => { + it('POST /auth/token/obtain should return an access token and refresh token values and a success message', async () => { + const res = await requestWithSupertest + .post('/api/auth/token/obtain') + .send({ + fields: { + username: testData.users.user1.username, + password: testData.strongPassword, + }, + }); + + expect(res.status).toEqual(201); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Success'); + }); + + it('POST /auth/token/obtain should throw a 401 error if the username does not exist in the DB', async () => { + const res = await requestWithSupertest + .post('/api/auth/token/obtain') + .send({ + fields: { + username: testData.invalidUsername, + password: testData.strongPassword, + }, + }); + + expect(res.status).toEqual(401); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Invalid username or password'); + }); + + it('POST /auth/token/obtain should throw a 401 error if the password is invalid', async () => { + const res = await requestWithSupertest + .post('/api/auth/token/obtain') + .send({ + fields: { + username: testData.users.user1.username, + password: testData.invalidPassword, + }, + }); + + expect(res.status).toEqual(401); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Invalid username or password'); + }); + }); + + describe('Refresh Access Token Endpoint', () => { + it('GET /auth/token/refresh should refresh the access and refresh tokens', async () => { + const accessToken = await generateToken( + { username: 'John', userId: 1, isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const refreshToken = await generateToken( + { username: 'John', userId: 1, isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .get('/api/auth/token/refresh') + .set('Cookie', [ + `accessToken=${accessToken}`, + `refreshToken=${refreshToken}`, + ]); + + expect(res.status).toEqual(200); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Success'); + }); + }); + + describe('Change Password Endpoint', () => { + it('PUT /auth/change-password/ should change a password', async () => { + const accessToken = await generateToken( + { username: 'John', userId: 2, isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .put('/api/auth/change-password') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + currentPassword: testData.strongPassword, + newPassword: testData.strongPassword2, + }, + }); + + expect(res.status).toEqual(200); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Password updated successfully'); + }); + + it('PUT /auth/change-password/ should throw 401 error if the current password is not valid', async () => { + const accessToken = await generateToken( + { username: 'John', userId: 2, isSuperuser: true }, + config.tokenSecret, + '1H', + ); + + const res = await requestWithSupertest + .put('/api/auth/change-password') + .set('Cookie', [`accessToken=${accessToken}`]) + .send({ + fields: { + currentPassword: testData.invalidPassword, + newPassword: testData.strongPassword2, + }, + }); + + expect(res.status).toEqual(401); + expect(res.type).toEqual(expect.stringContaining('json')); + expect(res.body).toHaveProperty('message'); + expect(res.body.message).toBe('Invalid current password'); + }); + }); +}); diff --git a/src/constants/dbTables.js b/src/db/schema.js similarity index 98% rename from src/constants/dbTables.js rename to src/db/schema.js index 921a068..64daaa0 100644 --- a/src/constants/dbTables.js +++ b/src/db/schema.js @@ -98,7 +98,7 @@ module.exports = { type: 'NUMERIC', primaryKey: false, notNull: true, - unique: true, + unique: false, foreignKey: { table: '_users', column: 'id' }, }, diff --git a/src/index.js b/src/index.js index ddb8876..d4e0295 100755 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,11 @@ const authRoutes = require('./routes/auth'); const swaggerFile = require('./swagger/swagger.json'); const { setupExtensions } = require('./extensions'); -const { createDefaultTables } = require('./controllers/auth'); +const { + createDefaultTables, + createInitialUser, +} = require('./controllers/auth'); + const { runCLICommands } = require('./commands'); const app = express(); @@ -75,9 +79,10 @@ if (config.rateLimit.enabled) { app.use(limiter); } -//If Auth mode is activated then create auth tables in the DB +//If Auth mode is activated then create auth tables in the DB & create a super user if there are no users in the DB if (config.auth) { createDefaultTables(); + createInitialUser(); } else { console.warn( 'Warning: Soul is running in open mode without authentication or authorization for API endpoints. Please be aware that your API endpoints will not be secure.', diff --git a/src/middlewares/api.js b/src/middlewares/api.js index 1e3adb0..707aa34 100644 --- a/src/middlewares/api.js +++ b/src/middlewares/api.js @@ -1,10 +1,15 @@ const config = require('../config'); -const { registerUser } = require('../controllers/auth'); -const { apiConstants } = require('../constants/'); +const { registerUser, isUsernameTaken } = require('../controllers/auth'); +const { apiConstants, dbConstants } = require('../constants/'); +const { removeFields } = require('../utils'); -const processRequest = async (req, res, next) => { +const { SALT, HASHED_PASSWORD, IS_SUPERUSER } = apiConstants.fields._users; +const { reservedTableNames } = dbConstants; + +const processRowRequest = async (req, res, next) => { const resource = req.params.name; - const method = req.method; + const { method, body } = req; + const fields = body.fields; // If the user sends a request when auth is set to false, throw an error if (apiConstants.defaultRoutes.includes(resource) && !config.auth) { @@ -13,15 +18,33 @@ const processRequest = async (req, res, next) => { }); } - // Execute user registration function - if (resource === '_users' && method === 'POST' && config.auth) { + // Redirect this request to the registerUser controller => POST /api/tables/_users/rows + if (resource === '_users' && method === 'POST') { return registerUser(req, res); } + // Remove some fields for this request and check the username field => PUT /api/tables/_users/rows + if (resource === '_users' && method === 'PUT') { + // check if the username is taken + if (fields.username) { + if (isUsernameTaken(fields.username)) { + return res + .status(409) + .send({ message: 'This username is already taken' }); + } + } + + /** + * remove some user fields from the request like (is_superuser, hashed_password, salt). + * NOTE: password can be updated via the /change-password API and superuser status can be only updated from the CLI + */ + removeFields([req.body.fields], [SALT, IS_SUPERUSER, HASHED_PASSWORD]); + } + next(); }; -const processResponse = async (req, res, next) => { +const processRowResponse = async (req, res, next) => { // Extract payload data const resource = req.params.name; const status = req.response.status; @@ -29,24 +52,30 @@ const processResponse = async (req, res, next) => { // Remove some fields from the response if (resource === '_users') { - removeFields(payload.data, ['salt', 'hashed_password']); + removeFields(payload.data, [SALT, HASHED_PASSWORD]); } res.status(status).send(payload); next(); }; -const removeFields = async (rows, fields) => { - const newPayload = rows.map((row) => { - fields.map((field) => { - delete row[field]; - }); - }); +const processTableRequest = async (req, res, next) => { + const { method, body, baseUrl } = req; - return newPayload; + // if the user tries to create a table with the reserved table names throw an error. Request => POST /api/tables + if (baseUrl === apiConstants.baseTableUrl && method === 'POST') { + if (reservedTableNames.includes(body.name)) { + return res.status(409).send({ + message: `The table name is reserved. Please choose a different name for the table. Table name: ${body.name}.`, + }); + } + } + + next(); }; module.exports = { - processRequest, - processResponse, + processRowRequest, + processRowResponse, + processTableRequest, }; diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index 41894c1..c4e4e1e 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -41,10 +41,19 @@ const isAuthenticated = async (req, res, next) => { .send({ message: 'Permission not defined for this role' }); } - const permission = permissions[0]; - const httpMethod = httpVerbs[verb].toLowerCase(); + // If the user has permission on the table in at least in one of the roles then allow access on the table + let hasPermission = false; - if (toBoolean(permission[httpMethod])) { + permissions.some((resource) => { + const httpMethod = httpVerbs[verb].toLowerCase(); + + if (toBoolean(resource[httpMethod])) { + hasPermission = true; + return true; + } + }); + + if (hasPermission) { next(); } else { return res.status(403).send({ message: 'Not authorized' }); @@ -53,6 +62,7 @@ const isAuthenticated = async (req, res, next) => { next(); } } catch (error) { + console.log(error); res.status(401).send({ message: error.message }); } }; diff --git a/src/routes/rows.js b/src/routes/rows.js index 6992e3e..8da4a4d 100644 --- a/src/routes/rows.js +++ b/src/routes/rows.js @@ -3,7 +3,7 @@ const express = require('express'); const controllers = require('../controllers/rows'); const { broadcast } = require('../middlewares/broadcast'); const { validator } = require('../middlewares/validation'); -const { processRequest, processResponse } = require('../middlewares/api'); +const { processRowRequest, processRowResponse } = require('../middlewares/api'); const { isAuthenticated } = require('../middlewares/auth'); const schema = require('../schemas/rows'); @@ -13,15 +13,15 @@ router.get( '/:name/rows', isAuthenticated, validator(schema.listTableRows), - processRequest, + processRowRequest, controllers.listTableRows, - processResponse, + processRowResponse, ); router.post( '/:name/rows', isAuthenticated, validator(schema.insertRowInTable), - processRequest, + processRowRequest, controllers.insertRowInTable, broadcast, ); @@ -30,12 +30,13 @@ router.get( isAuthenticated, validator(schema.getRowInTableByPK), controllers.getRowInTableByPK, - processResponse, + processRowResponse, ); router.put( '/:name/rows/:pks', isAuthenticated, validator(schema.updateRowInTableByPK), + processRowRequest, controllers.updateRowInTableByPK, broadcast, ); diff --git a/src/routes/tables.js b/src/routes/tables.js index 22e62c5..11ba003 100644 --- a/src/routes/tables.js +++ b/src/routes/tables.js @@ -4,6 +4,7 @@ const controllers = require('../controllers/tables'); const { validator } = require('../middlewares/validation'); const schema = require('../schemas/tables'); const { isAuthenticated } = require('../middlewares/auth'); +const { processTableRequest } = require('../middlewares/api'); const router = express.Router(); @@ -13,18 +14,22 @@ router.get( validator(schema.listTables), controllers.listTables, ); + router.post( '/', + processTableRequest, isAuthenticated, validator(schema.createTable), controllers.createTable, ); + router.get( '/:name', isAuthenticated, validator(schema.getTableSchema), controllers.getTableSchema, ); + router.delete( '/:name', isAuthenticated, diff --git a/src/schemas/auth.js b/src/schemas/auth.js index 741554b..45f4362 100644 --- a/src/schemas/auth.js +++ b/src/schemas/auth.js @@ -20,7 +20,7 @@ const refreshAccessToken = Joi.object({ const changePassword = Joi.object({ query: Joi.object().required(), - params: Joi.object({ userId: Joi.string().required() }).required(), + params: Joi.object().required(), body: Joi.object({ fields: Joi.object({ diff --git a/src/services/rowService.js b/src/services/rowService.js index 02a0fd0..177cdcf 100644 --- a/src/services/rowService.js +++ b/src/services/rowService.js @@ -37,7 +37,9 @@ module.exports = (db) => { save(data) { // wrap text values in quotes - const fieldsString = Object.keys(data.fields).join(', '); + const fieldsString = Object.keys(data.fields) + .map((field) => `'${field}'`) + .join(', '); // wrap text values in quotes const valuesString = Object.values(data.fields).map((value) => value); diff --git a/src/swagger/index.js b/src/swagger/index.js index 4cee5e1..c65d585 100644 --- a/src/swagger/index.js +++ b/src/swagger/index.js @@ -31,6 +31,10 @@ const doc = { name: 'Rows', description: 'Rows endpoints', }, + { + name: 'Auth', + description: 'Auth endpoints', + }, ], securityDefinitions: {}, definitions: { @@ -122,6 +126,69 @@ const doc = { TransactionRequestBody: { $ref: '#/definitions/Transaction', }, + ObtainAccessTokenRequestBody: { + fields: { + username: '@john', + password: 'Ak22#cPM33@v*#', + }, + }, + + ObtainAccessTokenSuccessResponse: { + message: 'Success', + data: { + userId: 1, + }, + }, + + InvalidCredentialErrorResponse: { + message: 'Invalid username or password', + }, + + UserRegisterationRequestBody: { + fields: { + username: '@john', + password: 'Ak22#cPM33@v*#', + }, + }, + + WeakPasswordErrorResponse: { + message: 'This password is weak, please use another password', + }, + + UsernameTakenErrorResponse: { + message: 'This username is taken', + }, + + DefaultRoleNotCreatedErrorResponse: { + message: 'Please restart soul so a default role can be created', + }, + + UserNotFoundErrorResponse: { + message: 'User not found', + }, + + InvalidRefreshTokenErrorResponse: { + message: 'Invalid refresh token', + }, + + ChangePasswordRequestBody: { + fields: { + currentPassword: 'Ak22#cPM33@v*#', + newPassword: 'hKB33o@3245CD$', + }, + }, + + ChangePasswordSuccessResponse: { + message: 'Password updated successfully', + data: { id: 1, username: '@john' }, + }, + + RefreshAccessTokenSuccessResponse: { + message: 'Success', + data: { userId: 1 }, + }, + + InvalidPasswordErrorResponse: { message: 'Invalid password' }, }, }; diff --git a/src/swagger/swagger.json b/src/swagger/swagger.json index 11ad8ca..05e2e66 100644 --- a/src/swagger/swagger.json +++ b/src/swagger/swagger.json @@ -19,6 +19,10 @@ { "name": "Rows", "description": "Rows endpoints" + }, + { + "name": "Auth", + "description": "Auth endpoints" } ], "schemes": ["http", "https"], @@ -143,6 +147,9 @@ }, "403": { "description": "Forbidden" + }, + "409": { + "description": "Conflict" } } } @@ -271,6 +278,9 @@ }, "403": { "description": "Forbidden" + }, + "409": { + "description": "Conflict" } } }, @@ -313,6 +323,9 @@ }, "403": { "description": "Forbidden" + }, + "409": { + "description": "Conflict" } } } @@ -441,6 +454,9 @@ }, "403": { "description": "Forbidden" + }, + "409": { + "description": "Conflict" } } }, @@ -492,30 +508,37 @@ }, "/api/auth/token/obtain": { "post": { - "description": "", + "tags": ["Auth"], + "summary": "Obtain Access Token", + "description": "Endpoint to generate access and refresh tokens", "parameters": [ { "name": "body", "in": "body", + "required": true, "schema": { - "type": "object", - "properties": { - "fields": { - "example": "any" - } - } + "$ref": "#/definitions/ObtainAccessTokenRequestBody" } } ], "responses": { "201": { - "description": "Created" + "description": "Access token and Refresh token generated", + "schema": { + "$ref": "#/definitions/ObtainAccessTokenSuccessResponse" + } }, "400": { "description": "Bad Request" }, "401": { - "description": "Unauthorized" + "description": "Invalid username or password error", + "schema": { + "$ref": "#/definitions/InvalidCredentialErrorResponse" + } + }, + "404": { + "description": "Not Found" }, "500": { "description": "Internal Server Error" @@ -525,47 +548,65 @@ }, "/api/auth/token/refresh": { "get": { - "description": "", + "tags": ["Auth"], + "summary": "Refresh Access Token", + "description": "Endpoint to refresh access and refresh tokens", "parameters": [], "responses": { - "201": { - "description": "Created" + "200": { + "description": "Access token refreshed", + "schema": { + "$ref": "#/definitions/RefreshAccessTokenSuccessResponse" + } }, "400": { "description": "Bad Request" }, "401": { - "description": "Unauthorized" + "description": "Invalid refresh token error", + "schema": { + "$ref": "#/definitions/InvalidRefreshTokenErrorResponse" + } + }, + "403": { + "description": "Forbidden" } } } }, "/api/auth/change-password": { "put": { - "description": "", + "tags": ["Auth"], + "summary": "Change Password", + "description": "Endpoint to change a password", "parameters": [ { "name": "body", "in": "body", + "required": true, "schema": { - "type": "object", - "properties": { - "fields": { - "example": "any" - } - } + "$ref": "#/definitions/ChangePasswordRequestBody" } } ], "responses": { - "201": { - "description": "Created" + "200": { + "description": "Weak password error", + "schema": { + "$ref": "#/definitions/ChangePasswordSuccessResponse" + } }, "400": { - "description": "Bad Request" + "description": "Weak password error", + "schema": { + "$ref": "#/definitions/WeakPasswordErrorResponse" + } }, "401": { - "description": "Unauthorized" + "description": "User not found error", + "schema": { + "$ref": "#/definitions/InvalidPasswordErrorResponse" + } }, "403": { "description": "Forbidden" @@ -850,6 +891,181 @@ }, "TransactionRequestBody": { "$ref": "#/definitions/Transaction" + }, + "ObtainAccessTokenRequestBody": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "@john" + }, + "password": { + "type": "string", + "example": "Ak22#cPM33@v*#" + } + } + } + } + }, + "ObtainAccessTokenSuccessResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Success" + }, + "data": { + "type": "object", + "properties": { + "userId": { + "type": "number", + "example": 1 + } + } + } + } + }, + "InvalidCredentialErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid username or password" + } + } + }, + "UserRegisterationRequestBody": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "properties": { + "username": { + "type": "string", + "example": "@john" + }, + "password": { + "type": "string", + "example": "Ak22#cPM33@v*#" + } + } + } + } + }, + "WeakPasswordErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "This password is weak, please use another password" + } + } + }, + "UsernameTakenErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "This username is taken" + } + } + }, + "DefaultRoleNotCreatedErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Please restart soul so a default role can be created" + } + } + }, + "UserNotFoundErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "User not found" + } + } + }, + "InvalidRefreshTokenErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid refresh token" + } + } + }, + "ChangePasswordRequestBody": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string", + "example": "Ak22#cPM33@v*#" + }, + "newPassword": { + "type": "string", + "example": "hKB33o@3245CD$" + } + } + } + } + }, + "ChangePasswordSuccessResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password updated successfully" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1 + }, + "username": { + "type": "string", + "example": "@john" + } + } + } + } + }, + "RefreshAccessTokenSuccessResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Success" + }, + "data": { + "type": "object", + "properties": { + "userId": { + "type": "number", + "example": 1 + } + } + } + } + }, + "InvalidPasswordErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Invalid password" + } + } } } } diff --git a/src/tests/index.js b/src/tests/index.js index 464b604..c1e4dec 100644 --- a/src/tests/index.js +++ b/src/tests/index.js @@ -17,7 +17,7 @@ const dropTestDatabase = async (path = 'test.db') => { if (fs.existsSync(path + '-wal')) { try { - await Promise.allSettled(unlink(path + '-wal'), unlink(path + '-shm')); + await Promise.allSettled([unlink(path + '-wal'), unlink(path + '-shm')]); } catch (error) { console.error('there was an error:', error); } diff --git a/src/tests/testData.js b/src/tests/testData.js index bb0408c..21c1e72 100644 --- a/src/tests/testData.js +++ b/src/tests/testData.js @@ -6,37 +6,37 @@ const testNames = [ { firstName: 'Olivia', lastName: 'William', - createdAt: '2012-01-08 00:00:00' + createdAt: '2012-01-08 00:00:00', }, { firstName: 'William', lastName: 'Kim', createdAt: '2013-01-08 00:00:00' }, { firstName: 'Sophia', lastName: 'Singh', createdAt: '2013-02-08 00:00:00' }, { firstName: 'James', lastName: 'Rodriguez', - createdAt: '2013-03-08 00:00:00' + createdAt: '2013-03-08 00:00:00', }, { firstName: 'Ava', lastName: 'Patel', createdAt: '2013-01-04 00:00:00' }, { firstName: 'Benjamin', lastName: 'Garcia', - createdAt: '2015-01-08 00:00:00' + createdAt: '2015-01-08 00:00:00', }, { firstName: 'Isabella', lastName: 'Nguyen', - createdAt: '2014-01-08 00:00:00' + createdAt: '2014-01-08 00:00:00', }, { firstName: 'Ethan', lastName: 'Lee', createdAt: '2016-01-08 00:00:00' }, { firstName: 'Mia', lastName: 'Wilson', createdAt: '2017-01-08 00:00:00' }, { firstName: 'Alexander', lastName: 'William', - createdAt: '2018-01-08 00:00:00' + createdAt: '2018-01-08 00:00:00', }, { firstName: 'Charlotte', lastName: 'Hernandez', - createdAt: '2019-01-08 00:00:00' + createdAt: '2019-01-08 00:00:00', }, { firstName: 'Liam', lastName: 'Gonzalez', createdAt: '2020-01-08 00:00:00' }, { firstName: 'Emma', lastName: 'Gomez', createdAt: '2021-01-08 00:00:00' }, @@ -46,17 +46,30 @@ const testNames = [ { firstName: 'Abigail', lastName: 'Williams', - createdAt: '2023-02-10 00:00:00' + createdAt: '2023-02-10 00:00:00', }, { firstName: 'Elijah', lastName: 'Hall', createdAt: '2023-04-02 00:00:00' }, { firstName: 'Mila', lastName: 'Flores', createdAt: '2023-05-13 00:00:00' }, { firstName: 'Evelyn', lastName: 'Morales', - createdAt: '2023-06-05 00:00:00' + createdAt: '2023-06-05 00:00:00', }, { firstName: 'Logan', lastName: 'Collins', createdAt: '2023-06-07 00:00:00' }, - { firstName: null, lastName: 'Flores', createdAt: '2023-06-09 00:00:00' } + { firstName: null, lastName: 'Flores', createdAt: '2023-06-09 00:00:00' }, ]; -module.exports = { testNames }; +const testData = { + strongPassword: 'HeK34#C44DMJ', + strongPassword2: 'Mk22#c9@Cv!K', + weakPassword: '12345678', + invalidUsername: 'invalid_username', + invalidPassword: 'invalid_password', + users: { + user1: { username: 'Jane' }, + user2: { username: 'Mike' }, + user3: { username: 'John' }, + }, +}; + +module.exports = { testNames, testData }; diff --git a/src/utils/index.js b/src/utils/index.js index 134d5d6..28f9faa 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -57,6 +57,16 @@ const toBoolean = (value) => { throw new Error('Invalid value. Cannot convert to boolean.'); }; +const removeFields = async (rows, fields) => { + const newPayload = rows.map((row) => { + fields.map((field) => { + delete row[field]; + }); + }); + + return newPayload; +}; + module.exports = { hashPassword, comparePasswords, @@ -64,4 +74,5 @@ module.exports = { generateToken, decodeToken, toBoolean, + removeFields, };