Merge pull request #148 from thevahidal/authentication_and_authorization
Add feature to create default db tables
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
# Top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*.{js,jsx,ts,tsx}]
|
||||
quote_type = single
|
||||
|
||||
[*]
|
||||
# Set default charset to utf-8
|
||||
charset = utf-8
|
||||
|
||||
11
.env.sample
11
.env.sample
@@ -3,11 +3,22 @@ CORE_PORT=8000
|
||||
NODE_ENV=development
|
||||
|
||||
CORS_ORIGIN_WHITELIST=http://localhost:3000,http://127.0.0.1:3000
|
||||
AUTH=false
|
||||
|
||||
RATE_LIMIT_ENABLED=false
|
||||
RATE_LIMIT_WINDOW_MS=1000
|
||||
RATE_LIMIT_MAX_REQUESTS=10
|
||||
|
||||
JWT_SECRET=ABCD23DCAA
|
||||
JWT_EXPIRATION_TIME=10H
|
||||
|
||||
INITIAL_USER_USERNAME=<your_username>
|
||||
INITIAL_USER_PASSWORD=<your_password>
|
||||
|
||||
DB=foobar.db
|
||||
|
||||
START_WITH_STUDIO=false
|
||||
|
||||
TOKEN_SECRET=ABCD23DCAA
|
||||
ACCESS_TOKEN_EXPIRATION_TIME=10H
|
||||
REFRESH_TOKEN_EXPIRATION_TIME=2D
|
||||
|
||||
0
.vscode/settings.json
vendored
Normal file
0
.vscode/settings.json
vendored
Normal file
68
README.md
68
README.md
@@ -19,6 +19,8 @@ Install Soul CLI with npm
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Running Soul
|
||||
|
||||
Soul is command line tool, after installing it,
|
||||
Run `soul -d sqlite.db -p 8000` and it'll start a REST API on [http://localhost:8000](http://localhost:8000) and a Websocket server on [ws://localhost:8000](ws://localhost:8000).
|
||||
|
||||
@@ -32,6 +34,15 @@ Options:
|
||||
-p, --port Port to listen on [number]
|
||||
-r, --rate-limit-enabled Enable rate limiting [boolean]
|
||||
-c, --cors CORS whitelist origins [string]
|
||||
-a, --auth Enable authentication and authorization [boolean]
|
||||
|
||||
-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]
|
||||
-rtet, --refreshtokenexpirationtime Refresh Token Expiration Time [string]
|
||||
-S, --studio Start Soul Studio in parallel
|
||||
--help Show help
|
||||
|
||||
@@ -45,6 +56,50 @@ curl http://localhost:8000/api/tables
|
||||
|
||||
It should return a list of the tables inside `sqlite.db` database.
|
||||
|
||||
### 2. Running Soul in Auth mode
|
||||
|
||||
To run Soul in auth mode, allowing login and signup features with authorization capabilities in your database tables, follow these steps:
|
||||
|
||||
Run the Soul command with the necessary parameters:
|
||||
|
||||
|
||||
```
|
||||
|
||||
soul --d foobar.db -a -ts <your_jwt_secret_value> -atet=4H -rtet=3D -iuu=john -iup=<your_password>
|
||||
|
||||
```
|
||||
|
||||
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 <your_jwt_secret_value> 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.
|
||||
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
|
||||
|
||||
- 60M: Represents a duration of 60 minutes.
|
||||
- 5H: Represents a duration of 5 hours.
|
||||
- 1D: Represents a duration of 1 day.
|
||||
|
||||
NOTE: It is crucial to securely store a copy of the `-ts`(`Token Secret`) value used in Soul. Once you pass this values, make sure to keep a backup because you will need it every time you restart Soul. Losing this secret values can result in a situation where all of your users are blocked from accessing Soul.
|
||||
|
||||
### 3. Updating Super Users
|
||||
|
||||
To modify a superuser information in a database, you can utilize the `updatesuperuser` command. This command allows you to change a superuser's `password` or upgrade/downgrade a normal user to a `superuser`. Below is an example of how to use it:
|
||||
|
||||
```
|
||||
soul --d foobar.db updatesuperuser --id=1 password=<new_password_for_the_user> // Update the password for the superuser with ID 1
|
||||
|
||||
soul --d foobar.db updatesuperuser --id=1 --is_superuser=true // Upgrade the user with ID 1 to a superuser
|
||||
|
||||
soul --d foobar.db updatesuperuser --id=1 --is_superuser=false // Revoke the superuser role from the superuser with ID 1
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
API documentation is available while the project is running at [http://localhost:8000/api/docs](http://localhost:8000/api/docs)
|
||||
@@ -63,8 +118,8 @@ A collection of projects that revolve around the Soul ecosystem.
|
||||
|
||||
- [Soul Studio](https://github.com/thevahidal/soul-studio) provides a GUI to work with your database.
|
||||
|
||||
Right now Soul Studio is in early stages of development and not useful to work with.
|
||||
|
||||
Right now Soul Studio is in early stages of development and not useful to work with.
|
||||
|
||||
<p align="center">
|
||||
<img src='docs/soul-studio.png' style="">
|
||||
</p>
|
||||
@@ -83,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.
|
||||
@@ -91,7 +154,6 @@ npm run dev # Start the dev server
|
||||
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are always welcome!
|
||||
|
||||
1166
package-lock.json
generated
1166
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,18 @@
|
||||
},
|
||||
"homepage": "https://github.com/thevahidal/soul#readme",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^8.1.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"check-password-strength": "^2.0.7",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-winston": "^4.2.0",
|
||||
"joi": "^17.8.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"soul-studio": "^0.0.1",
|
||||
"swagger-ui-express": "^4.6.1",
|
||||
"winston": "^3.8.2",
|
||||
|
||||
60
src/cli.js
60
src/cli.js
@@ -46,11 +46,69 @@ if (process.env.NO_CLI !== 'true') {
|
||||
type: 'string',
|
||||
demandOption: false,
|
||||
})
|
||||
.options('a', {
|
||||
alias: 'auth',
|
||||
describe: 'Enable authentication and authorization',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
demandOption: false,
|
||||
})
|
||||
.options('ts', {
|
||||
alias: 'tokensecret',
|
||||
describe: 'JWT secret for the access and refresh tokens',
|
||||
type: 'string',
|
||||
default: null,
|
||||
demandOption: false,
|
||||
})
|
||||
.options('atet', {
|
||||
alias: 'accesstokenexpirationtime',
|
||||
describe: 'JWT expiration time for access token',
|
||||
type: 'string',
|
||||
default: '5H',
|
||||
demandOption: false,
|
||||
})
|
||||
.options('rtet', {
|
||||
alias: 'refreshtokenexpirationtime',
|
||||
describe: 'JWT expiration time for refresh token',
|
||||
type: 'string',
|
||||
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',
|
||||
type: 'boolean',
|
||||
demandOption: false
|
||||
demandOption: false,
|
||||
})
|
||||
.command('updatesuperuser', 'Update a superuser', (yargs) => {
|
||||
return yargs
|
||||
.option('id', {
|
||||
describe: 'The ID of the superuser you want to update',
|
||||
type: 'number',
|
||||
demandOption: true,
|
||||
})
|
||||
.option('password', {
|
||||
describe: 'The new password for the superuser you want to update',
|
||||
type: 'string',
|
||||
demandOption: false,
|
||||
})
|
||||
.option('is_superuser', {
|
||||
describe: 'The role of the superuser you want to update',
|
||||
type: 'boolean',
|
||||
demandOption: false,
|
||||
});
|
||||
})
|
||||
.help(true).argv;
|
||||
}
|
||||
|
||||
22
src/commands.js
Normal file
22
src/commands.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { yargs } = require('./cli');
|
||||
const { updateSuperuser } = require('./controllers/auth');
|
||||
|
||||
const { argv } = yargs;
|
||||
|
||||
const runCLICommands = () => {
|
||||
//If the updatesuperuser command is passed from the CLI execute the updatesuperuser function
|
||||
if (argv._.includes('updatesuperuser')) {
|
||||
const { id, password, is_superuser } = argv;
|
||||
|
||||
if (!password && !is_superuser) {
|
||||
console.log(
|
||||
'Please provide either the --password or --is_superuser flag when using the updateuser command.',
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
updateSuperuser({ id, password, is_superuser });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { runCLICommands };
|
||||
@@ -2,7 +2,7 @@ const dotenv = require('dotenv');
|
||||
const Joi = require('joi');
|
||||
const path = require('path');
|
||||
|
||||
const { yargs, usage, options } = require('../cli');
|
||||
const { yargs } = require('../cli');
|
||||
|
||||
const { argv } = yargs;
|
||||
|
||||
@@ -20,6 +20,7 @@ const envVarsSchema = Joi.object()
|
||||
VERBOSE: Joi.string().valid('console', null).default(null),
|
||||
|
||||
CORS_ORIGIN_WHITELIST: Joi.string().default('*'),
|
||||
AUTH: Joi.boolean().default(false),
|
||||
|
||||
RATE_LIMIT_ENABLED: Joi.boolean().default(false),
|
||||
RATE_LIMIT_WINDOW_MS: Joi.number().positive().default(1000),
|
||||
@@ -27,7 +28,14 @@ const envVarsSchema = Joi.object()
|
||||
|
||||
EXTENSIONS: Joi.string().default(null),
|
||||
|
||||
START_WITH_STUDIO: Joi.boolean().default(false)
|
||||
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'),
|
||||
})
|
||||
.unknown();
|
||||
|
||||
@@ -51,10 +59,34 @@ if (argv.cors) {
|
||||
env.CORS_ORIGIN_WHITELIST = argv.cors;
|
||||
}
|
||||
|
||||
if (argv.auth) {
|
||||
env.AUTH = argv.auth;
|
||||
}
|
||||
|
||||
if (argv['rate-limit-enabled']) {
|
||||
env.RATE_LIMIT_ENABLED = argv['rate-limit-enabled'];
|
||||
}
|
||||
|
||||
if (argv.tokensecret) {
|
||||
env.TOKEN_SECRET = argv.tokensecret;
|
||||
}
|
||||
|
||||
if (argv.accesstokenexpirationtime) {
|
||||
env.ACCESS_TOKEN_EXPIRATION_TIME = argv.accesstokenexpirationtime;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -80,6 +112,19 @@ module.exports = {
|
||||
origin: argv.cors?.split(',') ||
|
||||
envVars.CORS_ORIGIN_WHITELIST?.split(',') || ['*'],
|
||||
},
|
||||
|
||||
auth: argv.auth || envVars.AUTH,
|
||||
tokenSecret: argv.tokensecret || envVars.TOKEN_SECRET,
|
||||
accessTokenExpirationTime:
|
||||
argv.accesstokenexpirationtime || envVars.ACCESS_TOKEN_EXPIRATION_TIME,
|
||||
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,
|
||||
@@ -90,5 +135,5 @@ module.exports = {
|
||||
path: argv.extensions || envVars.EXTENSIONS,
|
||||
},
|
||||
|
||||
startWithStudio: argv.studio || envVars.START_WITH_STUDIO
|
||||
startWithStudio: argv.studio || envVars.START_WITH_STUDIO,
|
||||
};
|
||||
|
||||
17
src/constants/api.js
Normal file
17
src/constants/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
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: {
|
||||
TOO_WEAK: 'Too weak',
|
||||
WEAK: 'Weak',
|
||||
},
|
||||
};
|
||||
6
src/constants/httpVerbs.js
Normal file
6
src/constants/httpVerbs.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
POST: 'CREATE',
|
||||
GET: 'READ',
|
||||
PUT: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
};
|
||||
5
src/constants/index.js
Normal file
5
src/constants/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const dbConstants = require('./tables');
|
||||
const apiConstants = require('./api');
|
||||
const constantRoles = require('./roles');
|
||||
|
||||
module.exports = { dbConstants, apiConstants, constantRoles };
|
||||
3
src/constants/roles.js
Normal file
3
src/constants/roles.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
DEFAULT_ROLE: 'default',
|
||||
};
|
||||
13
src/constants/tables.js
Normal file
13
src/constants/tables.js
Normal file
@@ -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',
|
||||
};
|
||||
711
src/controllers/auth.js
Normal file
711
src/controllers/auth.js
Normal file
@@ -0,0 +1,711 @@
|
||||
const { tableService, rowService } = require('../services');
|
||||
const { constantRoles, apiConstants, dbConstants } = require('../constants');
|
||||
const schema = require('../db/schema');
|
||||
const config = require('../config');
|
||||
const {
|
||||
hashPassword,
|
||||
checkPasswordStrength,
|
||||
comparePasswords,
|
||||
generateToken,
|
||||
decodeToken,
|
||||
toBoolean,
|
||||
} = require('../utils');
|
||||
|
||||
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(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(USER_TABLE, schema.userSchema);
|
||||
}
|
||||
|
||||
// create _users_roles table
|
||||
if (!usersRolesTable) {
|
||||
// create the _users_roles table
|
||||
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(ROLE_TABLE, schema.roleSchema);
|
||||
|
||||
// create a default role in the _roles table
|
||||
const role = rowService.save({
|
||||
tableName: ROLE_TABLE,
|
||||
fields: { name: constantRoles.DEFAULT_ROLE },
|
||||
});
|
||||
roleId = role.lastInsertRowid;
|
||||
}
|
||||
|
||||
// create _roles_permissions table
|
||||
if (!rolesPermissionTable && roleId) {
|
||||
// create the _roles_permissions table
|
||||
tableService.createTable(
|
||||
ROLE_PERMISSIONS_TABLE,
|
||||
schema.rolePermissionSchema,
|
||||
{
|
||||
multipleUniqueConstraints: {
|
||||
name: 'unique_role_table',
|
||||
fields: ['role_id', 'table_name'],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// fetch all DB tables
|
||||
const tables = tableService.listTables();
|
||||
|
||||
// add permission for the default role (for each db table)
|
||||
const permissions = [];
|
||||
for (const table of tables) {
|
||||
permissions.push({
|
||||
role_id: roleId,
|
||||
table_name: table.name,
|
||||
create: 'false',
|
||||
read: 'true',
|
||||
update: 'false',
|
||||
delete: 'false',
|
||||
});
|
||||
}
|
||||
|
||||
// store the permissions in the db
|
||||
rowService.bulkWrite({
|
||||
tableName: ROLE_PERMISSIONS_TABLE,
|
||||
fields: permissions,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateSuperuser = async (fields) => {
|
||||
const { id, password, is_superuser } = fields;
|
||||
let newHashedPassword, newSalt;
|
||||
let fieldsString = '';
|
||||
|
||||
try {
|
||||
// find the user by using the id field
|
||||
const users = rowService.get({
|
||||
tableName: USER_TABLE,
|
||||
whereString: 'WHERE id=?',
|
||||
whereStringValues: [id],
|
||||
});
|
||||
|
||||
// abort if the id is invalid
|
||||
if (users.length === 0) {
|
||||
console.log('The user id you passed does not exist in the database');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// check if the is_superuser field is passed
|
||||
if (is_superuser !== undefined) {
|
||||
fieldsString = `is_superuser = '${is_superuser}'`;
|
||||
}
|
||||
|
||||
// if the password is sent from the CLI, update it
|
||||
if (password) {
|
||||
// check if the password is weak
|
||||
if (
|
||||
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
|
||||
checkPasswordStrength(password),
|
||||
)
|
||||
) {
|
||||
console.log('Your password should be at least 8 charachters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
//hash the password
|
||||
const { hashedPassword, salt } = await hashPassword(password, 10);
|
||||
newHashedPassword = hashedPassword;
|
||||
newSalt = salt;
|
||||
|
||||
fieldsString = `${
|
||||
fieldsString ? fieldsString + ', ' : ''
|
||||
}hashed_password = '${newHashedPassword}', salt = '${newSalt}'`;
|
||||
}
|
||||
|
||||
// update the user
|
||||
rowService.update({
|
||||
tableName: USER_TABLE,
|
||||
lookupField: `id`,
|
||||
fieldsString,
|
||||
pks: `${id}`,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'User updated successfully, you can now restart soul without the updateuser command',
|
||||
);
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
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: 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
|
||||
if (
|
||||
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
|
||||
checkPasswordStrength(password),
|
||||
)
|
||||
) {
|
||||
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
|
||||
const { salt, hashedPassword } = await hashPassword(password, 10);
|
||||
|
||||
// create the user
|
||||
const newUser = rowService.save({
|
||||
tableName: USER_TABLE,
|
||||
fields: {
|
||||
username,
|
||||
salt,
|
||||
hashed_password: hashedPassword,
|
||||
is_superuser: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
// find the default role from the DB
|
||||
let defaultRole = rowService.get({
|
||||
tableName: ROLE_TABLE,
|
||||
whereString: 'WHERE name=?',
|
||||
whereStringValues: [constantRoles.DEFAULT_ROLE],
|
||||
});
|
||||
|
||||
if (defaultRole.length <= 0) {
|
||||
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_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 });
|
||||
}
|
||||
};
|
||||
|
||||
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: USER_TABLE,
|
||||
whereString: 'WHERE username=?',
|
||||
whereStringValues: [username],
|
||||
});
|
||||
|
||||
if (users.length <= 0) {
|
||||
return res.status(401).send({ message: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// check if the password is valid
|
||||
const user = users[0];
|
||||
const isMatch = await comparePasswords(password, user.hashed_password);
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
let userRoles, permissions, roleIds;
|
||||
|
||||
// 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],
|
||||
});
|
||||
|
||||
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,
|
||||
roleIds,
|
||||
permissions,
|
||||
};
|
||||
|
||||
// generate an access token
|
||||
const accessToken = await generateToken(
|
||||
{ subject: 'accessToken', ...payload },
|
||||
config.tokenSecret,
|
||||
config.accessTokenExpirationTime,
|
||||
);
|
||||
|
||||
// generate a refresh token
|
||||
const refreshToken = await generateToken(
|
||||
{ subject: 'refreshToken', ...payload },
|
||||
config.tokenSecret,
|
||||
config.refreshTokenExpirationTime,
|
||||
);
|
||||
|
||||
// set the token in the cookie
|
||||
let cookieOptions = { httpOnly: true, secure: false, Path: '/' };
|
||||
res.cookie('accessToken', accessToken, cookieOptions);
|
||||
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({
|
||||
message: error.message,
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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(
|
||||
req.cookies.refreshToken,
|
||||
config.tokenSecret,
|
||||
);
|
||||
|
||||
// find the user
|
||||
const users = rowService.get({
|
||||
tableName: USER_TABLE,
|
||||
whereString: 'WHERE id=?',
|
||||
whereStringValues: [payload.userId],
|
||||
});
|
||||
|
||||
if (users.length <= 0) {
|
||||
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];
|
||||
|
||||
// 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', ...newPayload },
|
||||
config.tokenSecret,
|
||||
config.accessTokenExpirationTime,
|
||||
);
|
||||
|
||||
// generate a refresh token
|
||||
const refreshToken = await generateToken(
|
||||
{ subject: 'refreshToken', ...newPayload },
|
||||
config.tokenSecret,
|
||||
config.refreshTokenExpirationTime,
|
||||
);
|
||||
|
||||
// set the token in the cookie
|
||||
let cookieOptions = { httpOnly: true, secure: false, Path: '/' };
|
||||
res.cookie('accessToken', accessToken, cookieOptions);
|
||||
res.cookie('refreshToken', refreshToken, cookieOptions);
|
||||
|
||||
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(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: USER_TABLE,
|
||||
whereString: 'WHERE id=?',
|
||||
whereStringValues: [userInfo.userId],
|
||||
});
|
||||
|
||||
if (users.length <= 0) {
|
||||
return res.status(401).send({ message: 'User not found' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// check if the users current password is valid
|
||||
const isMatch = await comparePasswords(
|
||||
currentPassword,
|
||||
user.hashed_password,
|
||||
);
|
||||
|
||||
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
|
||||
if (
|
||||
[apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes(
|
||||
checkPasswordStrength(newPassword),
|
||||
)
|
||||
) {
|
||||
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
|
||||
const { salt, hashedPassword } = await hashPassword(newPassword, 10);
|
||||
|
||||
user.salt = salt;
|
||||
user.hashed_password = hashedPassword;
|
||||
|
||||
// update the user
|
||||
rowService.update({
|
||||
tableName: USER_TABLE,
|
||||
lookupField: `id`,
|
||||
fieldsString: `hashed_password = '${hashedPassword}', salt = '${salt}'`,
|
||||
pks: `${user.id}`,
|
||||
});
|
||||
|
||||
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,
|
||||
registerUser,
|
||||
obtainAccessToken,
|
||||
refreshAccessToken,
|
||||
changePassword,
|
||||
createInitialUser,
|
||||
isUsernameTaken,
|
||||
};
|
||||
313
src/controllers/auth.test.js
Normal file
313
src/controllers/auth.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,6 @@
|
||||
const db = require('../db/index');
|
||||
const { rowService } = require('../services');
|
||||
|
||||
const quotePrimaryKeys = (pks) => {
|
||||
const primaryKeys = pks.split(',');
|
||||
const quotedPks = primaryKeys.map((id) => `'${id}'`).join(',');
|
||||
return quotedPks;
|
||||
};
|
||||
|
||||
const operators = {
|
||||
eq: '=',
|
||||
lt: '<',
|
||||
@@ -15,11 +9,11 @@ const operators = {
|
||||
gte: '>=',
|
||||
neq: '!=',
|
||||
null: 'IS NULL',
|
||||
notnull: 'IS NOT NULL'
|
||||
notnull: 'IS NOT NULL',
|
||||
};
|
||||
|
||||
// Return paginated rows of a table
|
||||
const listTableRows = async (req, res) => {
|
||||
const listTableRows = async (req, res, next) => {
|
||||
/*
|
||||
#swagger.tags = ['Rows']
|
||||
#swagger.summary = 'List Rows'
|
||||
@@ -66,7 +60,7 @@ const listTableRows = async (req, res) => {
|
||||
_ordering,
|
||||
_schema,
|
||||
_extend,
|
||||
_filters = ''
|
||||
_filters = '',
|
||||
} = req.query;
|
||||
|
||||
const page = parseInt(_page);
|
||||
@@ -80,7 +74,7 @@ const listTableRows = async (req, res) => {
|
||||
let filters = [];
|
||||
|
||||
// split the filters by comma(,) except when in an array
|
||||
const re = /,(?![^\[]*?\])/;
|
||||
const re = /,(?![^[]*?\])/;
|
||||
try {
|
||||
filters = _filters.split(re).map((filter) => {
|
||||
//NOTE: When using the _filter parameter, the values are split using the ":" sign, like this (_filters=Total__eq:1). However, if the user sends a date value, such as (_filters=InvoiceDate__eq:2010-01-08 00:00:00), there will be additional colon (":") signs present.
|
||||
@@ -98,7 +92,7 @@ const listTableRows = async (req, res) => {
|
||||
fieldOperator = 'eq';
|
||||
} else if (!operators[fieldOperator]) {
|
||||
throw new Error(
|
||||
`Invalid field operator '${fieldOperator}' for field '${field}'. You can only use the following operators after the '${field}' field: __lt, __gt, __lte, __gte, __eq, __neq.`
|
||||
`Invalid field operator '${fieldOperator}' for field '${field}'. You can only use the following operators after the '${field}' field: __lt, __gt, __lte, __gte, __eq, __neq.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +106,7 @@ const listTableRows = async (req, res) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +155,7 @@ const listTableRows = async (req, res) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -236,7 +230,7 @@ const listTableRows = async (req, res) => {
|
||||
|
||||
if (!foreignKey) {
|
||||
throw new Error(
|
||||
`Foreign key not found for extended field '${extendedField}'`
|
||||
`Foreign key not found for extended field '${extendedField}'`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,7 +248,7 @@ const listTableRows = async (req, res) => {
|
||||
joinedTableFields
|
||||
.map(
|
||||
(joinedTableField) =>
|
||||
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`
|
||||
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`,
|
||||
)
|
||||
.join(', ') +
|
||||
' ) as ' +
|
||||
@@ -277,7 +271,7 @@ const listTableRows = async (req, res) => {
|
||||
if (foreignKeyError.error) {
|
||||
return res.status(400).json({
|
||||
message: foreignKeyError.message,
|
||||
error: foreignKeyError.error
|
||||
error: foreignKeyError.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -291,7 +285,7 @@ const listTableRows = async (req, res) => {
|
||||
orderString,
|
||||
limit,
|
||||
page: limit * (page - 1),
|
||||
whereStringValues
|
||||
whereStringValues,
|
||||
});
|
||||
|
||||
// parse json extended files
|
||||
@@ -311,10 +305,10 @@ const listTableRows = async (req, res) => {
|
||||
const total = rowService.getCount({
|
||||
tableName,
|
||||
whereString,
|
||||
whereStringValues
|
||||
whereStringValues,
|
||||
});
|
||||
|
||||
const next =
|
||||
const nextPage =
|
||||
data.length === limit
|
||||
? `/tables/${tableName}/rows?${params}_limit=${_limit}&_page=${
|
||||
page + 1
|
||||
@@ -327,16 +321,15 @@ const listTableRows = async (req, res) => {
|
||||
}`
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
data,
|
||||
total,
|
||||
next,
|
||||
previous
|
||||
});
|
||||
req.response = {
|
||||
status: 200,
|
||||
payload: { data, total, next: nextPage, previous },
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -367,7 +360,7 @@ const insertRowInTable = async (req, res, next) => {
|
||||
|
||||
// Remove null values from fields for accurate query construction.
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(queryFields).filter(([_, value]) => value !== null)
|
||||
Object.entries(queryFields).filter(([, value]) => value !== null),
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -383,14 +376,15 @@ const insertRowInTable = async (req, res, next) => {
|
||||
*/
|
||||
res.status(201).json({
|
||||
message: 'Row inserted',
|
||||
data
|
||||
data,
|
||||
});
|
||||
|
||||
req.broadcast = {
|
||||
type: 'INSERT',
|
||||
data: {
|
||||
pk: data.lastInsertRowid,
|
||||
...fields
|
||||
}
|
||||
...fields,
|
||||
},
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -404,13 +398,13 @@ const insertRowInTable = async (req, res, next) => {
|
||||
*/
|
||||
res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get a row by pk
|
||||
const getRowInTableByPK = async (req, res) => {
|
||||
const getRowInTableByPK = async (req, res, next) => {
|
||||
/*
|
||||
#swagger.tags = ['Rows']
|
||||
#swagger.summary = 'Retrieve Row'
|
||||
@@ -465,7 +459,7 @@ const getRowInTableByPK = async (req, res) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -542,7 +536,7 @@ const getRowInTableByPK = async (req, res) => {
|
||||
joinedTableFields
|
||||
.map(
|
||||
(joinedTableField) =>
|
||||
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`
|
||||
`'${joinedTableField.name}', ${joinedTableName}.${joinedTableField.name}`,
|
||||
)
|
||||
.join(', ') +
|
||||
' ) as ' +
|
||||
@@ -557,7 +551,7 @@ const getRowInTableByPK = async (req, res) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -569,7 +563,7 @@ const getRowInTableByPK = async (req, res) => {
|
||||
tableName,
|
||||
extendString,
|
||||
lookupField,
|
||||
pks
|
||||
pks,
|
||||
});
|
||||
|
||||
// parse json extended files
|
||||
@@ -588,17 +582,16 @@ const getRowInTableByPK = async (req, res) => {
|
||||
if (data.length === 0) {
|
||||
return res.status(404).json({
|
||||
message: 'Row not found',
|
||||
error: 'not_found'
|
||||
error: 'not_found',
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
data
|
||||
});
|
||||
req.response = { status: 200, payload: { data } };
|
||||
next();
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -652,7 +645,7 @@ const updateRowInTableByPK = async (req, res, next) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -671,7 +664,7 @@ const updateRowInTableByPK = async (req, res, next) => {
|
||||
if (fieldsString === '') {
|
||||
return res.status(400).json({
|
||||
message: 'No fields provided',
|
||||
error: 'no_fields_provided'
|
||||
error: 'no_fields_provided',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -680,26 +673,26 @@ const updateRowInTableByPK = async (req, res, next) => {
|
||||
tableName,
|
||||
fieldsString,
|
||||
lookupField,
|
||||
pks
|
||||
pks,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Row updated',
|
||||
data
|
||||
data,
|
||||
});
|
||||
req.broadcast = {
|
||||
type: 'UPDATE',
|
||||
_lookup_field: lookupField,
|
||||
data: {
|
||||
pks: pks.split(','),
|
||||
...fields
|
||||
}
|
||||
...fields,
|
||||
},
|
||||
};
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -744,7 +737,7 @@ const deleteRowInTableByPK = async (req, res, next) => {
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -754,26 +747,26 @@ const deleteRowInTableByPK = async (req, res, next) => {
|
||||
|
||||
if (data.changes === 0) {
|
||||
res.status(404).json({
|
||||
error: 'not_found'
|
||||
error: 'not_found',
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
message: 'Row deleted',
|
||||
data
|
||||
data,
|
||||
});
|
||||
req.broadcast = {
|
||||
type: 'DELETE',
|
||||
_lookup_field: lookupField,
|
||||
data: {
|
||||
pks: pks.split(',')
|
||||
}
|
||||
pks: pks.split(','),
|
||||
},
|
||||
};
|
||||
next();
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
message: error.message,
|
||||
error: error
|
||||
error: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -783,5 +776,5 @@ module.exports = {
|
||||
insertRowInTable,
|
||||
getRowInTableByPK,
|
||||
updateRowInTableByPK,
|
||||
deleteRowInTableByPK
|
||||
deleteRowInTableByPK,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ const { not } = require('joi');
|
||||
const supertest = require('supertest');
|
||||
|
||||
const app = require('../index');
|
||||
const config = require('../config');
|
||||
const { generateToken } = require('../utils');
|
||||
|
||||
const requestWithSupertest = supertest(app);
|
||||
|
||||
function queryString(params) {
|
||||
@@ -14,7 +17,15 @@ function queryString(params) {
|
||||
|
||||
describe('Rows Endpoints', () => {
|
||||
it('GET /tables/:name/rows should return a list of all rows', async () => {
|
||||
const res = await requestWithSupertest.get('/api/tables/users/rows');
|
||||
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.type).toEqual(expect.stringContaining('json'));
|
||||
@@ -26,17 +37,23 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows?_limit=8&_schema=firstName,lastName&_ordering:-firstName&_page=2: should query the rows by the provided query params', async () => {
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const params = {
|
||||
_search: 'a',
|
||||
_ordering: '-firstName',
|
||||
_schema: 'firstName,lastName',
|
||||
_limit: 8,
|
||||
_page: 2
|
||||
_page: 2,
|
||||
};
|
||||
const query = queryString(params);
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?${query}`
|
||||
);
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?${query}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
@@ -48,34 +65,46 @@ describe('Rows Endpoints', () => {
|
||||
expect(res.body.next).toEqual(
|
||||
`/tables/users/rows?${queryString({
|
||||
...params,
|
||||
_page: params._page + 1
|
||||
}).toString()}`
|
||||
_page: params._page + 1,
|
||||
}).toString()}`,
|
||||
);
|
||||
|
||||
expect(res.body.previous).toEqual(
|
||||
`/tables/users/rows?${queryString({
|
||||
...params,
|
||||
_page: params._page - 1
|
||||
}).toString()}`
|
||||
_page: params._page - 1,
|
||||
}).toString()}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should return a null field', async () => {
|
||||
const res = await requestWithSupertest.get(
|
||||
'/api/tables/users/rows?_filters=firstName__null,lastName__notnull'
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.get('/api/tables/users/rows?_filters=firstName__null,lastName__notnull')
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.body.data[0].firstName).toBeNull();
|
||||
expect(res.body.data[0].lastName).not.toBeNull();
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should successfully retrieve users created after 2010-01-01 00:00:00.', async () => {
|
||||
const date = '2010-01-01 00:00:00';
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?_filters=createdAt__gte:${date}`
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const date = '2010-01-01 00:00:00';
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?_filters=createdAt__gte:${date}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
res.body.data.map((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const referenceDate = new Date(date);
|
||||
@@ -90,11 +119,17 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should successfully retrieve users created before 2008-01-20 00:00:00.', async () => {
|
||||
const date = '2008-01-20 00:00:00';
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?_filters=createdAt__lte:${date}`
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const date = '2008-01-20 00:00:00';
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?_filters=createdAt__lte:${date}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
res.body.data.map((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const referenceDate = new Date(date);
|
||||
@@ -109,11 +144,17 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should successfully retrieve users created at 2013-01-08 00:00:00', async () => {
|
||||
const date = '2013-01-08 00:00:00';
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?_filters=createdAt__eq:${date}`
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const date = '2013-01-08 00:00:00';
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
res.body.data.map((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const referenceDate = new Date(date);
|
||||
@@ -128,22 +169,34 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should successfully retrieve users created at 2007-01-08 00:00:00', async () => {
|
||||
const date = '2007-01-08 00:00:00';
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?_filters=createdAt__eq:${date}`
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const date = '2007-01-08 00:00:00';
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
//There are no users that are created at 2007-01-08 00:00:00 so the API should return empty data
|
||||
expect(res.body.data).toHaveLength(0);
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows: should successfully retrieve users that are not created at 2021-01-08 00:00:00', async () => {
|
||||
const date = '2021-01-08 00:00:00';
|
||||
const res = await requestWithSupertest.get(
|
||||
`/api/tables/users/rows?_filters=createdAt__neq:${date}`
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const date = '2021-01-08 00:00:00';
|
||||
const res = await requestWithSupertest
|
||||
.get(`/api/tables/users/rows?_filters=createdAt__neq:${date}`)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
res.body.data.map((user) => {
|
||||
const createdAt = new Date(user.createdAt);
|
||||
const referenceDate = new Date(date);
|
||||
@@ -158,16 +211,33 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('POST /tables/:name/rows should insert a new row and return the lastInsertRowid', 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: { firstName: 'Jane', lastName: 'Doe' } });
|
||||
|
||||
expect(res.status).toEqual(201);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
expect(res.body).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows/:pks should return a row by its primary key', async () => {
|
||||
const res = await requestWithSupertest.get('/api/tables/users/rows/1');
|
||||
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.type).toEqual(expect.stringContaining('json'));
|
||||
expect(res.body).toHaveProperty('data');
|
||||
@@ -177,37 +247,65 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('PUT /tables/:name/rows/:pks should update a row by its primary key and return the number of changes', 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: { firstName: 'Jane', lastName: 'Doe' } });
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
});
|
||||
|
||||
it('DELETE /tables/:name/rows/:pks should delete a row by its primary key and return the number of changes', async () => {
|
||||
const res = await requestWithSupertest.delete('/api/tables/users/rows/1');
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.delete('/api/tables/users/rows/1')
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
});
|
||||
|
||||
it('POST /tables/:name/rows should insert a new row if any of the value of the object being inserted is null', async () => {
|
||||
const res = await requestWithSupertest.post('/api/tables/users/rows').send({
|
||||
fields: {
|
||||
firstName: null,
|
||||
lastName: 'Doe',
|
||||
email: null,
|
||||
username: 'Jane'
|
||||
}
|
||||
});
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
const res = await requestWithSupertest
|
||||
.post('/api/tables/users/rows')
|
||||
.send({
|
||||
fields: {
|
||||
firstName: null,
|
||||
lastName: 'Doe',
|
||||
email: null,
|
||||
username: 'Jane',
|
||||
},
|
||||
})
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
expect(res.status).toEqual(201);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
expect(res.body).toHaveProperty('data');
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows should return values if any of the IDs from the array match the user ID.', async () => {
|
||||
const res = await requestWithSupertest.get(
|
||||
'/api/tables/users/rows?_filters=id:[2,3]'
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.get('/api/tables/users/rows?_filters=id:[2,3]')
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.body).toHaveProperty('data');
|
||||
expect(res.body.data).toEqual(expect.any(Array));
|
||||
@@ -215,9 +313,17 @@ describe('Rows Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name/rows should return values if the provided ID matches the user ID.', async () => {
|
||||
const res = await requestWithSupertest.get(
|
||||
'/api/tables/users/rows?_filters=id:2,firstName:Michael,lastName:Lee'
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.get(
|
||||
'/api/tables/users/rows?_filters=id:2,firstName:Michael,lastName:Lee',
|
||||
)
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.body).toHaveProperty('data');
|
||||
expect(res.body.data).toEqual(expect.any(Array));
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
const supertest = require('supertest');
|
||||
|
||||
const app = require('../index');
|
||||
const { generateToken } = require('../utils');
|
||||
const config = require('../config');
|
||||
|
||||
const requestWithSupertest = supertest(app);
|
||||
|
||||
describe('Tables Endpoints', () => {
|
||||
it('GET /tables should return a list of all tables', async () => {
|
||||
const res = await requestWithSupertest.get('/api/tables');
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.get('/api/tables')
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
expect(res.body).toHaveProperty('data');
|
||||
@@ -14,33 +26,42 @@ describe('Tables Endpoints', () => {
|
||||
});
|
||||
|
||||
it('POST /tables should create a new table and return generated schema', async () => {
|
||||
const res = await requestWithSupertest.post('/api/tables').send({
|
||||
name: 'pets',
|
||||
autoAddCreatedAt: true,
|
||||
autoAddUpdatedAt: false,
|
||||
schema: [
|
||||
{
|
||||
name: 'owner',
|
||||
type: 'INTEGER',
|
||||
foreignKey: {
|
||||
table: 'users',
|
||||
column: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.post('/api/tables')
|
||||
.send({
|
||||
name: 'pets',
|
||||
autoAddCreatedAt: true,
|
||||
autoAddUpdatedAt: false,
|
||||
schema: [
|
||||
{
|
||||
name: 'owner',
|
||||
type: 'INTEGER',
|
||||
foreignKey: {
|
||||
table: 'users',
|
||||
column: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'petId',
|
||||
unique: true,
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
notNull: true,
|
||||
},
|
||||
{
|
||||
name: 'petId',
|
||||
unique: true,
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
})
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
expect(res.status).toEqual(201);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
@@ -53,7 +74,16 @@ describe('Tables Endpoints', () => {
|
||||
});
|
||||
|
||||
it('GET /tables/:name should return schema of the table', async () => {
|
||||
const res = await requestWithSupertest.get('/api/tables/users');
|
||||
const accessToken = await generateToken(
|
||||
{ username: 'John', isSuperuser: true },
|
||||
config.tokenSecret,
|
||||
'1H',
|
||||
);
|
||||
|
||||
const res = await requestWithSupertest
|
||||
.get('/api/tables/users')
|
||||
.set('Cookie', [`accessToken=${accessToken}`]);
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.type).toEqual(expect.stringContaining('json'));
|
||||
expect(res.body).toHaveProperty('data');
|
||||
|
||||
114
src/db/schema.js
Normal file
114
src/db/schema.js
Normal file
@@ -0,0 +1,114 @@
|
||||
module.exports = {
|
||||
roleSchema: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
|
||||
userSchema: [
|
||||
{
|
||||
name: 'username',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'hashed_password',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'salt',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'is_superuser',
|
||||
type: 'BOOLEAN',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
],
|
||||
|
||||
rolePermissionSchema: [
|
||||
{
|
||||
name: 'role_id',
|
||||
type: 'NUMERIC',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
foreignKey: { table: '_roles', column: 'id' },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'table_name',
|
||||
type: 'TEXT',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'create',
|
||||
type: 'BOOLEAN',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'read',
|
||||
type: 'BOOLEAN',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'update',
|
||||
type: 'BOOLEAN',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'delete',
|
||||
type: 'BOOLEAN',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
},
|
||||
],
|
||||
|
||||
usersRoleSchema: [
|
||||
{
|
||||
name: 'user_id',
|
||||
type: 'NUMERIC',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
foreignKey: { table: '_users', column: 'id' },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'role_id',
|
||||
type: 'NUMERIC',
|
||||
primaryKey: false,
|
||||
notNull: true,
|
||||
unique: false,
|
||||
foreignKey: { table: '_roles', column: 'id' },
|
||||
},
|
||||
],
|
||||
};
|
||||
76
src/index.js
76
src/index.js
@@ -1,37 +1,47 @@
|
||||
#! /usr/bin/env node
|
||||
|
||||
const express = require("express");
|
||||
const bodyParser = require("body-parser");
|
||||
const winston = require("winston");
|
||||
const expressWinston = require("express-winston");
|
||||
const cors = require("cors");
|
||||
const rateLimit = require("express-rate-limit");
|
||||
const swaggerUi = require("swagger-ui-express");
|
||||
const express = require('express');
|
||||
const bodyParser = require('body-parser');
|
||||
const winston = require('winston');
|
||||
const expressWinston = require('express-winston');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
const config = require("./config/index");
|
||||
const db = require("./db/index");
|
||||
const rootRoutes = require("./routes/index");
|
||||
const tablesRoutes = require("./routes/tables");
|
||||
const rowsRoutes = require("./routes/rows");
|
||||
const swaggerFile = require("./swagger/swagger.json");
|
||||
const { setupExtensions } = require("./extensions");
|
||||
const config = require('./config/index');
|
||||
const db = require('./db/index');
|
||||
|
||||
const rootRoutes = require('./routes/index');
|
||||
const tablesRoutes = require('./routes/tables');
|
||||
const rowsRoutes = require('./routes/rows');
|
||||
const authRoutes = require('./routes/auth');
|
||||
|
||||
const swaggerFile = require('./swagger/swagger.json');
|
||||
const { setupExtensions } = require('./extensions');
|
||||
const {
|
||||
createDefaultTables,
|
||||
createInitialUser,
|
||||
} = require('./controllers/auth');
|
||||
|
||||
const { runCLICommands } = require('./commands');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get("/health", (req, res) => {
|
||||
res.send("OK");
|
||||
app.get('/health', (req, res) => {
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use(bodyParser.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
// Activate wal mode
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
|
||||
// Enable CORS
|
||||
let corsOrigin = config.cors.origin;
|
||||
|
||||
if (corsOrigin.includes("*")) {
|
||||
corsOrigin = "*";
|
||||
if (corsOrigin.includes('*')) {
|
||||
corsOrigin = '*';
|
||||
}
|
||||
|
||||
const corsOptions = { origin: corsOrigin };
|
||||
@@ -49,13 +59,14 @@ if (config.verbose !== null) {
|
||||
winston.format.json(),
|
||||
),
|
||||
meta: false,
|
||||
msg: "HTTP {{req.method}} {{req.url}}",
|
||||
msg: 'HTTP {{req.method}} {{req.url}}',
|
||||
expressFormat: true,
|
||||
|
||||
colorize: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (config.rateLimit.enabled) {
|
||||
const limiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
@@ -68,10 +79,25 @@ if (config.rateLimit.enabled) {
|
||||
app.use(limiter);
|
||||
}
|
||||
|
||||
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerFile));
|
||||
app.use("/api", rootRoutes);
|
||||
app.use("/api/tables", tablesRoutes);
|
||||
app.use("/api/tables", rowsRoutes);
|
||||
//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.',
|
||||
);
|
||||
}
|
||||
|
||||
// If the user has passed custom CLI commands run the command and exit to avoid running the server
|
||||
runCLICommands();
|
||||
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerFile));
|
||||
app.use('/api', rootRoutes);
|
||||
app.use('/api/tables', tablesRoutes);
|
||||
app.use('/api/tables', rowsRoutes);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
setupExtensions(app, db);
|
||||
|
||||
|
||||
81
src/middlewares/api.js
Normal file
81
src/middlewares/api.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const config = require('../config');
|
||||
const { registerUser, isUsernameTaken } = require('../controllers/auth');
|
||||
const { apiConstants, dbConstants } = require('../constants/');
|
||||
const { removeFields } = require('../utils');
|
||||
|
||||
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, 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) {
|
||||
return res.status(403).send({
|
||||
message: 'You can not access this endpoint while AUTH is set to false',
|
||||
});
|
||||
}
|
||||
|
||||
// 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 processRowResponse = async (req, res, next) => {
|
||||
// Extract payload data
|
||||
const resource = req.params.name;
|
||||
const status = req.response.status;
|
||||
const payload = req.response.payload;
|
||||
|
||||
// Remove some fields from the response
|
||||
if (resource === '_users') {
|
||||
removeFields(payload.data, [SALT, HASHED_PASSWORD]);
|
||||
}
|
||||
|
||||
res.status(status).send(payload);
|
||||
next();
|
||||
};
|
||||
|
||||
const processTableRequest = async (req, res, next) => {
|
||||
const { method, body, baseUrl } = req;
|
||||
|
||||
// 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 = {
|
||||
processRowRequest,
|
||||
processRowResponse,
|
||||
processTableRequest,
|
||||
};
|
||||
70
src/middlewares/auth.js
Normal file
70
src/middlewares/auth.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const config = require('../config');
|
||||
const { decodeToken, toBoolean } = require('../utils/index');
|
||||
const httpVerbs = require('../constants/httpVerbs');
|
||||
|
||||
const isAuthenticated = async (req, res, next) => {
|
||||
let payload;
|
||||
const { name: tableName } = req.params;
|
||||
const verb = req.method;
|
||||
|
||||
try {
|
||||
if (config.auth) {
|
||||
// extract the payload from the token and verify it
|
||||
try {
|
||||
payload = await decodeToken(
|
||||
req.cookies.accessToken,
|
||||
config.tokenSecret,
|
||||
);
|
||||
req.user = payload;
|
||||
} catch (error) {
|
||||
return res.status(403).send({ message: 'Invalid access token' });
|
||||
}
|
||||
|
||||
// if the user is a super_user, allow access on the resource
|
||||
if (toBoolean(payload.isSuperuser)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// if table_name is not passed from the router throw unauthorized error
|
||||
if (!tableName) {
|
||||
return res.status(403).send({ message: 'Not authorized' });
|
||||
}
|
||||
|
||||
// if the user is not a super user, check the users permission on the resource
|
||||
const permissions = payload.permissions.filter((row) => {
|
||||
return row.table_name === tableName;
|
||||
});
|
||||
|
||||
if (permissions.length <= 0) {
|
||||
return res
|
||||
.status(403)
|
||||
.send({ message: 'Permission not defined for this role' });
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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' });
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
res.status(401).send({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { isAuthenticated };
|
||||
@@ -1,6 +1,6 @@
|
||||
const { websocketSubscribers } = require('../websocket');
|
||||
|
||||
const broadcast = (req, res, next) => {
|
||||
const broadcast = (req) => {
|
||||
const data = req.broadcast;
|
||||
const { name: tableName } = req.params;
|
||||
|
||||
|
||||
29
src/routes/auth.js
Normal file
29
src/routes/auth.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const express = require('express');
|
||||
|
||||
const controllers = require('../controllers/auth');
|
||||
const { validator } = require('../middlewares/validation');
|
||||
const schema = require('../schemas/auth');
|
||||
const { isAuthenticated } = require('../middlewares/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post(
|
||||
'/token/obtain',
|
||||
validator(schema.obtainAccessToken),
|
||||
controllers.obtainAccessToken,
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/token/refresh',
|
||||
validator(schema.refreshAccessToken),
|
||||
controllers.refreshAccessToken,
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/change-password',
|
||||
validator(schema.changePassword),
|
||||
isAuthenticated,
|
||||
controllers.changePassword,
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -3,37 +3,49 @@ const express = require('express');
|
||||
const controllers = require('../controllers/rows');
|
||||
const { broadcast } = require('../middlewares/broadcast');
|
||||
const { validator } = require('../middlewares/validation');
|
||||
const { processRowRequest, processRowResponse } = require('../middlewares/api');
|
||||
const { isAuthenticated } = require('../middlewares/auth');
|
||||
const schema = require('../schemas/rows');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/:name/rows',
|
||||
isAuthenticated,
|
||||
validator(schema.listTableRows),
|
||||
controllers.listTableRows
|
||||
processRowRequest,
|
||||
controllers.listTableRows,
|
||||
processRowResponse,
|
||||
);
|
||||
router.post(
|
||||
'/:name/rows',
|
||||
isAuthenticated,
|
||||
validator(schema.insertRowInTable),
|
||||
processRowRequest,
|
||||
controllers.insertRowInTable,
|
||||
broadcast
|
||||
broadcast,
|
||||
);
|
||||
router.get(
|
||||
'/:name/rows/:pks',
|
||||
isAuthenticated,
|
||||
validator(schema.getRowInTableByPK),
|
||||
controllers.getRowInTableByPK
|
||||
controllers.getRowInTableByPK,
|
||||
processRowResponse,
|
||||
);
|
||||
router.put(
|
||||
'/:name/rows/:pks',
|
||||
isAuthenticated,
|
||||
validator(schema.updateRowInTableByPK),
|
||||
processRowRequest,
|
||||
controllers.updateRowInTableByPK,
|
||||
broadcast
|
||||
broadcast,
|
||||
);
|
||||
router.delete(
|
||||
'/:name/rows/:pks',
|
||||
isAuthenticated,
|
||||
validator(schema.deleteRowInTableByPK),
|
||||
controllers.deleteRowInTableByPK,
|
||||
broadcast
|
||||
broadcast,
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,16 +3,38 @@ const express = require('express');
|
||||
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();
|
||||
|
||||
router.get('/', validator(schema.listTables), controllers.listTables);
|
||||
router.post('/', validator(schema.createTable), controllers.createTable);
|
||||
router.get(
|
||||
'/',
|
||||
isAuthenticated,
|
||||
validator(schema.listTables),
|
||||
controllers.listTables,
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
processTableRequest,
|
||||
isAuthenticated,
|
||||
validator(schema.createTable),
|
||||
controllers.createTable,
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:name',
|
||||
isAuthenticated,
|
||||
validator(schema.getTableSchema),
|
||||
controllers.getTableSchema
|
||||
controllers.getTableSchema,
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:name',
|
||||
isAuthenticated,
|
||||
validator(schema.deleteTable),
|
||||
controllers.deleteTable,
|
||||
);
|
||||
router.delete('/:name', validator(schema.deleteTable), controllers.deleteTable);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
37
src/schemas/auth.js
Normal file
37
src/schemas/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const obtainAccessToken = Joi.object({
|
||||
query: Joi.object().required(),
|
||||
params: Joi.object({}).required(),
|
||||
|
||||
body: Joi.object({
|
||||
fields: Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required(),
|
||||
}).required(),
|
||||
}).required(),
|
||||
});
|
||||
|
||||
const refreshAccessToken = Joi.object({
|
||||
query: Joi.object().required(),
|
||||
params: Joi.object({}).required(),
|
||||
body: Joi.object({}).required(),
|
||||
});
|
||||
|
||||
const changePassword = Joi.object({
|
||||
query: Joi.object().required(),
|
||||
params: Joi.object().required(),
|
||||
|
||||
body: Joi.object({
|
||||
fields: Joi.object({
|
||||
currentPassword: Joi.string().required(),
|
||||
newPassword: Joi.string().required(),
|
||||
}).required(),
|
||||
}).required(),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
obtainAccessToken,
|
||||
refreshAccessToken,
|
||||
changePassword,
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
const app = require('./index');
|
||||
const { wss } = require('./websocket');
|
||||
@@ -9,7 +8,9 @@ const config = require('./config/index');
|
||||
|
||||
if (config.startWithStudio) {
|
||||
(async () => {
|
||||
const { handler: soulStudioHandler } = await import('soul-studio/build/handler.js');
|
||||
const { handler: soulStudioHandler } = await import(
|
||||
'soul-studio/build/handler.js'
|
||||
);
|
||||
app.use('/studio', soulStudioHandler);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
const { apiConstants } = require('../constants');
|
||||
|
||||
module.exports = (db) => {
|
||||
return {
|
||||
get(data) {
|
||||
const query = `SELECT ${data.schemaString} FROM ${data.tableName} ${data.extendString} ${data.whereString} ${data.orderString} LIMIT ? OFFSET ?`;
|
||||
const query = `SELECT ${data.schemaString || '*'} FROM ${
|
||||
data.tableName
|
||||
} ${data.extendString || ''} ${data.whereString || ''} ${
|
||||
data.orderString || ''
|
||||
} LIMIT ? OFFSET ?`;
|
||||
|
||||
const statement = db.prepare(query);
|
||||
const result = statement.all(
|
||||
...data.whereStringValues,
|
||||
data.limit,
|
||||
data.page
|
||||
data.limit || apiConstants.DEFAULT_PAGE_LIMIT,
|
||||
data.page || apiConstants.DEFAULT_PAGE_INDEX,
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
getById(data) {
|
||||
const pks = data.pks.split(',');
|
||||
const placeholders = pks.map((pk) => '?').join(',');
|
||||
const placeholders = pks.map(() => '?').join(',');
|
||||
const query = `SELECT ${data.schemaString} FROM ${data.tableName} ${data.extendString} WHERE ${data.tableName}.${data.lookupField} in (${placeholders})`;
|
||||
const statement = db.prepare(query);
|
||||
const result = statement.all(...pks);
|
||||
@@ -29,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);
|
||||
@@ -48,9 +58,28 @@ module.exports = (db) => {
|
||||
return result;
|
||||
},
|
||||
|
||||
bulkWrite(data) {
|
||||
const { tableName, fields } = data;
|
||||
const fieldNames = Object.keys(fields[0]);
|
||||
const valueSets = fields.map((row) => Object.values(row));
|
||||
|
||||
const placeholders = fieldNames.map(() => '?');
|
||||
const valuesString = valueSets
|
||||
.map(() => `(${placeholders.join(',')})`)
|
||||
.join(',');
|
||||
|
||||
const query = `INSERT INTO ${tableName} (${fieldNames
|
||||
.map((field) => `'${field}'`)
|
||||
.join(', ')}) VALUES ${valuesString}`;
|
||||
|
||||
const statement = db.prepare(query);
|
||||
const result = statement.run(...valueSets.flat());
|
||||
return result;
|
||||
},
|
||||
|
||||
update(data) {
|
||||
const pks = data.pks.split(',');
|
||||
const placeholders = pks.map((pk) => '?').join(',');
|
||||
const placeholders = pks.map(() => '?').join(',');
|
||||
const query = `UPDATE ${data.tableName} SET ${data.fieldsString} WHERE ${data.lookupField} in (${placeholders})`;
|
||||
const statement = db.prepare(query);
|
||||
const result = statement.run(...pks);
|
||||
@@ -59,7 +88,7 @@ module.exports = (db) => {
|
||||
|
||||
delete(data) {
|
||||
const pks = data.pks.split(',');
|
||||
const placeholders = pks.map((pk) => '?').join(',');
|
||||
const placeholders = pks.map(() => '?').join(',');
|
||||
const query = `DELETE FROM ${data.tableName} WHERE ${data.lookupField} in (${placeholders})`;
|
||||
const statement = db.prepare(query);
|
||||
const result = statement.run(...pks);
|
||||
|
||||
@@ -1,5 +1,131 @@
|
||||
module.exports = (db) => {
|
||||
return {
|
||||
get() {},
|
||||
createTable(tableName, schema, options = {}) {
|
||||
const {
|
||||
autoAddCreatedAt = true,
|
||||
autoAddUpdatedAt = true,
|
||||
multipleUniqueConstraints,
|
||||
} = options;
|
||||
|
||||
let indices = [];
|
||||
|
||||
let schemaString = schema
|
||||
.map(({ name, type, notNull, unique, primaryKey, foreignKey }) => {
|
||||
let column = `'${name}' '${type}'`;
|
||||
|
||||
if (notNull) {
|
||||
column += ' NOT NULL';
|
||||
}
|
||||
if (unique) {
|
||||
column += ' UNIQUE';
|
||||
}
|
||||
if (primaryKey) {
|
||||
column += ' PRIMARY KEY';
|
||||
}
|
||||
if (foreignKey) {
|
||||
column += ` REFERENCES ${foreignKey.table}(${foreignKey.column})`;
|
||||
}
|
||||
if (foreignKey && foreignKey.onDelete) {
|
||||
column += ` ON DELETE ${foreignKey.onDelete}`;
|
||||
}
|
||||
if (foreignKey && foreignKey.onUpdate) {
|
||||
column += ` ON UPDATE ${foreignKey.onUpdate}`;
|
||||
}
|
||||
|
||||
return column;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// add id if primary key is not defined
|
||||
if (!schema.find((field) => field.primaryKey)) {
|
||||
schemaString = `
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
${schemaString}
|
||||
`;
|
||||
}
|
||||
|
||||
// add created at and updated at
|
||||
if (autoAddCreatedAt) {
|
||||
schemaString = `${schemaString}, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP`;
|
||||
}
|
||||
|
||||
if (autoAddUpdatedAt) {
|
||||
schemaString = `${schemaString}, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP`;
|
||||
}
|
||||
|
||||
if (multipleUniqueConstraints) {
|
||||
schemaString = `${schemaString}, CONSTRAINT ${
|
||||
multipleUniqueConstraints.name
|
||||
} UNIQUE (${multipleUniqueConstraints.fields
|
||||
.map((field) => field)
|
||||
.join(' ,')})`;
|
||||
}
|
||||
|
||||
let indicesString = indices
|
||||
.map((field) => {
|
||||
return `
|
||||
CREATE INDEX ${tableName}_${field}_index
|
||||
ON ${tableName} (${field})
|
||||
`;
|
||||
})
|
||||
.join(';');
|
||||
|
||||
const query = `CREATE TABLE ${tableName} (${schemaString})`;
|
||||
|
||||
try {
|
||||
db.prepare(query).run();
|
||||
|
||||
if (indicesString) {
|
||||
db.prepare(indicesString).run();
|
||||
}
|
||||
|
||||
db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
|
||||
listTables(options = {}) {
|
||||
const { search, ordering, exclude } = options;
|
||||
|
||||
let query = `SELECT name FROM sqlite_master WHERE type IN ('table', 'view')`;
|
||||
|
||||
// if search is provided, search the tables
|
||||
// e.g. search=users
|
||||
if (search) {
|
||||
query += ` AND name LIKE $searchQuery`;
|
||||
}
|
||||
|
||||
// if exclude is passed don't return the some tables
|
||||
// e.g. exclude=['_users', '_roles']
|
||||
if (exclude) {
|
||||
const excludeTables = exclude.map((field) => `'${field}'`).join(' ,');
|
||||
query += `AND name NOT IN (${excludeTables});`;
|
||||
}
|
||||
|
||||
// if ordering is provided, order the tables
|
||||
// e.g. ordering=name (ascending) or ?_ordering=-name (descending)
|
||||
if (ordering) {
|
||||
query += ` ORDER BY $ordering`;
|
||||
}
|
||||
|
||||
try {
|
||||
const tables = db.prepare(query).all({
|
||||
searchQuery: `%${search}%`,
|
||||
ordering: `${ordering?.replace('-', '')} ${
|
||||
ordering?.startsWith('-') ? 'DESC' : 'ASC'
|
||||
}`,
|
||||
});
|
||||
return tables;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
},
|
||||
|
||||
checkTableExists(tableName) {
|
||||
const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`;
|
||||
const result = db.prepare(query).get();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -19,19 +19,16 @@
|
||||
{
|
||||
"name": "Rows",
|
||||
"description": "Rows endpoints"
|
||||
},
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": "Auth endpoints"
|
||||
}
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"schemes": ["http", "https"],
|
||||
"securityDefinitions": {},
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
@@ -46,9 +43,7 @@
|
||||
},
|
||||
"/api/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Root"
|
||||
],
|
||||
"tags": ["Root"],
|
||||
"summary": "Timestamp",
|
||||
"description": "Endpoint to return server timestamp",
|
||||
"parameters": [],
|
||||
@@ -61,9 +56,7 @@
|
||||
},
|
||||
"/api/transaction": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Root"
|
||||
],
|
||||
"tags": ["Root"],
|
||||
"summary": "Transaction",
|
||||
"description": "Endpoint to run any transaction, e.g. [{ \"query\": \"\" }, { \"statement\": \"\", \"values\": {} }, { \"query\": \"\" }]",
|
||||
"parameters": [
|
||||
@@ -88,9 +81,7 @@
|
||||
},
|
||||
"/api/tables/": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Tables"
|
||||
],
|
||||
"tags": ["Tables"],
|
||||
"summary": "List Tables",
|
||||
"description": "Endpoint to list all tables",
|
||||
"parameters": [
|
||||
@@ -115,13 +106,17 @@
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Tables"
|
||||
],
|
||||
"tags": ["Tables"],
|
||||
"summary": "Create Table",
|
||||
"description": "Endpoint to create a table",
|
||||
"parameters": [
|
||||
@@ -146,15 +141,22 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/CreateTableErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tables/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Tables"
|
||||
],
|
||||
"tags": ["Tables"],
|
||||
"summary": "Get Table Schema",
|
||||
"description": "Endpoint to get the schema of a table",
|
||||
"parameters": [
|
||||
@@ -172,13 +174,17 @@
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Tables"
|
||||
],
|
||||
"tags": ["Tables"],
|
||||
"summary": "Delete Table",
|
||||
"description": "Endpoint to delete a table",
|
||||
"parameters": [
|
||||
@@ -196,15 +202,19 @@
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tables/{name}/rows": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Rows"
|
||||
],
|
||||
"tags": ["Rows"],
|
||||
"summary": "List Rows",
|
||||
"description": "Endpoint to list rows of a table.",
|
||||
"parameters": [
|
||||
@@ -260,18 +270,22 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"Rows"
|
||||
],
|
||||
"tags": ["Rows"],
|
||||
"summary": "Insert Row",
|
||||
"description": "Insert a new row in a table",
|
||||
"parameters": [
|
||||
@@ -303,15 +317,22 @@
|
||||
"schema": {
|
||||
"$ref": "#/definitions/InsertRowErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/tables/{name}/rows/{pks}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Rows"
|
||||
],
|
||||
"tags": ["Rows"],
|
||||
"summary": "Retrieve Row",
|
||||
"description": "Retrieve a row by primary key",
|
||||
"parameters": [
|
||||
@@ -352,21 +373,22 @@
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Rows"
|
||||
],
|
||||
"tags": ["Rows"],
|
||||
"summary": "Update Row",
|
||||
"description": "Update a row by primary key",
|
||||
"parameters": [
|
||||
@@ -426,13 +448,20 @@
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Rows"
|
||||
],
|
||||
"tags": ["Rows"],
|
||||
"summary": "Delete Row",
|
||||
"description": "Delete a row by primary key",
|
||||
"parameters": [
|
||||
@@ -465,11 +494,128 @@
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/token/obtain": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Obtain Access Token",
|
||||
"description": "Endpoint to generate access and refresh tokens",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ObtainAccessTokenRequestBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Access token and Refresh token generated",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ObtainAccessTokenSuccessResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid username or password error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/InvalidCredentialErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/token/refresh": {
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Refresh Access Token",
|
||||
"description": "Endpoint to refresh access and refresh tokens",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Access token refreshed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/RefreshAccessTokenSuccessResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Invalid refresh token error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/InvalidRefreshTokenErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/change-password": {
|
||||
"put": {
|
||||
"tags": ["Auth"],
|
||||
"summary": "Change Password",
|
||||
"description": "Endpoint to change a password",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ChangePasswordRequestBody"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Weak password error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ChangePasswordSuccessResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Weak password error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/WeakPasswordErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "User not found error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/InvalidPasswordErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -718,11 +864,7 @@
|
||||
"properties": {
|
||||
"pks": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"example": [1, 2, 3],
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -740,11 +882,7 @@
|
||||
"properties": {
|
||||
"pks": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"example": [1, 2, 3],
|
||||
"items": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -753,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const { unlink } = require('fs/promises');
|
||||
|
||||
const db = require('../db/index');
|
||||
const { testNames } = require('./testData');
|
||||
|
||||
@@ -18,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);
|
||||
}
|
||||
@@ -27,13 +26,13 @@ const dropTestDatabase = async (path = 'test.db') => {
|
||||
|
||||
const createTestTable = (table = 'users') => {
|
||||
db.prepare(
|
||||
`CREATE TABLE ${table} (id INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, email TEXT, username TEXT, createdAt TEXT)`
|
||||
`CREATE TABLE ${table} (id INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, email TEXT, username TEXT, createdAt TEXT)`,
|
||||
).run();
|
||||
};
|
||||
|
||||
const insertIntoTestTable = (table = 'users') => {
|
||||
const statement = db.prepare(
|
||||
`INSERT INTO ${table} (firstName, lastName, createdAt) VALUES (?, ?, ?)`
|
||||
`INSERT INTO ${table} (firstName, lastName, createdAt) VALUES (?, ?, ?)`,
|
||||
);
|
||||
|
||||
for (const user of testNames) {
|
||||
@@ -45,5 +44,5 @@ module.exports = {
|
||||
dropTestTable,
|
||||
dropTestDatabase,
|
||||
createTestTable,
|
||||
insertIntoTestTable
|
||||
insertIntoTestTable,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
78
src/utils/index.js
Normal file
78
src/utils/index.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const { passwordStrength } = require('check-password-strength');
|
||||
|
||||
const hashPassword = async (password, saltRounds) => {
|
||||
const salt = await bcrypt.genSalt(saltRounds);
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
return { hashedPassword, salt };
|
||||
};
|
||||
|
||||
const comparePasswords = async (plainPassword, hashedPassword) => {
|
||||
const isMatch = await bcrypt.compare(plainPassword, hashedPassword);
|
||||
return isMatch;
|
||||
};
|
||||
|
||||
const checkPasswordStrength = (password) => {
|
||||
const value = passwordStrength(password).value;
|
||||
return value;
|
||||
};
|
||||
|
||||
const generateToken = async (payload, secret, expiresIn) => {
|
||||
return jwt.sign(payload, secret, { expiresIn });
|
||||
};
|
||||
|
||||
const decodeToken = async (token, secret) => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, secret);
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
};
|
||||
|
||||
const toBoolean = (value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const lowerCaseValue = value.toLowerCase();
|
||||
if (lowerCaseValue === 'true') {
|
||||
return true;
|
||||
} else if (lowerCaseValue === 'false') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
if (value === 1) {
|
||||
return true;
|
||||
} else if (value === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
checkPasswordStrength,
|
||||
generateToken,
|
||||
decodeToken,
|
||||
toBoolean,
|
||||
removeFields,
|
||||
};
|
||||
Reference in New Issue
Block a user