mirror of
https://github.com/sasjs/server.git
synced 2025-12-10 19:34:34 +00:00
chore(merge): Merge branch 'master' into authentication-with-jwt
This commit is contained in:
15
api/jest.config.js
Normal file
15
api/jest.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest/presets/js-with-ts',
|
||||
testEnvironment: 'node',
|
||||
// FIXME: improve test coverage and uncomment below lines
|
||||
// coverageThreshold: {
|
||||
// global: {
|
||||
// branches: 80,
|
||||
// functions: 80,
|
||||
// lines: 80,
|
||||
// statements: -10
|
||||
// }
|
||||
// },
|
||||
collectCoverageFrom: ['src/**/{!(index),}.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/build/']
|
||||
}
|
||||
25065
api/package-lock.json
generated
Normal file
25065
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
api/package.json
Normal file
65
api/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"description": "Api of SASjs server",
|
||||
"main": "./src/server.ts",
|
||||
"scripts": {
|
||||
"prestart": "npm run swagger",
|
||||
"start": "nodemon ./src/server.ts",
|
||||
"start:prod": "nodemon ./src/prod-server.ts",
|
||||
"build": "rimraf build && tsc",
|
||||
"swagger": "tsoa spec",
|
||||
"semantic-release": "semantic-release -d",
|
||||
"prepare": "[ -d .git ] && git config core.hooksPath ./.git-hooks || true",
|
||||
"test": "mkdir -p tmp && mkdir -p ../web/build && jest --coverage",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"package:lib": "npm run build && cp ./package.json build && cp README.md build && cd build && npm version \"5.0.0\" && npm pack"
|
||||
},
|
||||
"release": {
|
||||
"branches": [
|
||||
"master"
|
||||
]
|
||||
},
|
||||
"author": "Analytium Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "^2.23.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.17.1",
|
||||
"joi": "^17.4.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"mongoose": "^6.0.12",
|
||||
"mongoose-sequence": "^5.3.1",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.3",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"tsoa": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/mongoose-sequence": "^3.0.6",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^15.12.2",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/swagger-ui-express": "^4.1.3",
|
||||
"dotenv": "^10.0.0",
|
||||
"jest": "^27.0.6",
|
||||
"mongodb-memory-server": "^8.0.0",
|
||||
"nodemon": "^2.0.7",
|
||||
"prettier": "^2.3.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semantic-release": "^17.4.3",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"configuration": {
|
||||
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas",
|
||||
"sasJsPort": 5000
|
||||
}
|
||||
}
|
||||
19
api/src/app.ts
Normal file
19
api/src/app.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express'
|
||||
import morgan from 'morgan'
|
||||
import webRouter from './routes/web'
|
||||
import apiRouter from './routes/api'
|
||||
import { getWebBuildFolderPath } from './utils'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
app.use(morgan('tiny'))
|
||||
app.use(express.static('public'))
|
||||
|
||||
app.use('/', webRouter)
|
||||
app.use('/SASjsApi', apiRouter)
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
app.use(express.static(getWebBuildFolderPath()))
|
||||
|
||||
export default app
|
||||
80
api/src/controllers/Drive.ts
Normal file
80
api/src/controllers/Drive.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Security, Route, Tags, Example, Post, Body, Response } from 'tsoa'
|
||||
import { fileExists, readFile, createFile } from '@sasjs/utils'
|
||||
import { createFileTree, getTreeExample } from '.'
|
||||
|
||||
import { FileTree, isFileTree } from '../types'
|
||||
|
||||
interface DeployPayload {
|
||||
appLoc?: string
|
||||
fileTree: FileTree
|
||||
}
|
||||
|
||||
interface DeployResponse {
|
||||
status: string
|
||||
message: string
|
||||
example?: FileTree
|
||||
}
|
||||
|
||||
const fileTreeExample = getTreeExample()
|
||||
|
||||
const successResponse: DeployResponse = {
|
||||
status: 'success',
|
||||
message: 'Files deployed successfully to @sasjs/server.'
|
||||
}
|
||||
const invalidFormatResponse: DeployResponse = {
|
||||
status: 'failure',
|
||||
message: 'Provided not supported data format.',
|
||||
example: fileTreeExample
|
||||
}
|
||||
const execErrorResponse: DeployResponse = {
|
||||
status: 'failure',
|
||||
message: 'Deployment failed!'
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/drive')
|
||||
@Tags('Drive')
|
||||
export class DriveController {
|
||||
/**
|
||||
* Creates/updates files within SASjs Drive using provided payload.
|
||||
*
|
||||
*/
|
||||
@Example<DeployResponse>(successResponse)
|
||||
@Response<DeployResponse>(400, 'Invalid Format', invalidFormatResponse)
|
||||
@Response<DeployResponse>(500, 'Execution Error', execErrorResponse)
|
||||
@Post('/deploy')
|
||||
public async deploy(@Body() body: DeployPayload): Promise<DeployResponse> {
|
||||
return deploy(body)
|
||||
}
|
||||
|
||||
async readFile(filePath: string) {
|
||||
await this.validateFilePath(filePath)
|
||||
return await readFile(filePath)
|
||||
}
|
||||
|
||||
async updateFile(filePath: string, fileContent: string) {
|
||||
await this.validateFilePath(filePath)
|
||||
return await createFile(filePath, fileContent)
|
||||
}
|
||||
|
||||
private async validateFilePath(filePath: string) {
|
||||
if (!(await fileExists(filePath))) {
|
||||
throw 'DriveController: File does not exists.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deploy = async (data: DeployPayload) => {
|
||||
if (!isFileTree(data.fileTree)) {
|
||||
throw { code: 400, ...invalidFormatResponse }
|
||||
}
|
||||
|
||||
await createFileTree(
|
||||
data.fileTree.members,
|
||||
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
|
||||
).catch((err) => {
|
||||
throw { code: 500, ...execErrorResponse, ...err }
|
||||
})
|
||||
|
||||
return successResponse
|
||||
}
|
||||
151
api/src/controllers/Execution.ts
Normal file
151
api/src/controllers/Execution.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController } from './'
|
||||
import { readFile, fileExists, createFile } from '@sasjs/utils'
|
||||
import { configuration } from '../../package.json'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import { Session, TreeNode } from '../types'
|
||||
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
|
||||
export class ExecutionController {
|
||||
async execute(
|
||||
program = '',
|
||||
autoExec?: string,
|
||||
session?: Session,
|
||||
vars?: any,
|
||||
otherArgs?: any,
|
||||
returnJson?: boolean
|
||||
) {
|
||||
if (program) {
|
||||
if (!(await fileExists(program))) {
|
||||
throw 'ExecutionController: SAS file does not exist.'
|
||||
}
|
||||
|
||||
program = await readFile(program)
|
||||
|
||||
if (vars) {
|
||||
Object.keys(vars).forEach(
|
||||
(key: string) => (program = `%let ${key}=${vars[key]};\n${program}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sessionController = getSessionController()
|
||||
|
||||
if (!session) {
|
||||
session = await sessionController.getSession()
|
||||
session.inUse = true
|
||||
}
|
||||
|
||||
let log = path.join(session.path, 'log.log')
|
||||
|
||||
let webout = path.join(session.path, 'webout.txt')
|
||||
await createFile(webout, '')
|
||||
|
||||
program = `
|
||||
%let sasjsprocessmode=Stored Program;
|
||||
filename _webout "${webout}";
|
||||
${program}`
|
||||
|
||||
// if no files are uploaded filesNamesMap will be undefined
|
||||
if (otherArgs && otherArgs.filesNamesMap) {
|
||||
const uploadSasCode = await generateFileUploadSasCode(
|
||||
otherArgs.filesNamesMap,
|
||||
session.path
|
||||
)
|
||||
|
||||
//If sas code for the file is generated it will be appended to the top of sasCode
|
||||
if (uploadSasCode.length > 0) {
|
||||
program = `${uploadSasCode}` + program
|
||||
}
|
||||
}
|
||||
|
||||
const code = path.join(session.path, 'code.sas')
|
||||
if (!(await fileExists(code))) {
|
||||
await createFile(code, program)
|
||||
}
|
||||
|
||||
let additionalArgs: string[] = []
|
||||
if (autoExec) additionalArgs = ['-AUTOEXEC', autoExec]
|
||||
|
||||
const { stdout, stderr } = await execFilePromise(configuration.sasPath, [
|
||||
'-SYSIN',
|
||||
code,
|
||||
'-LOG',
|
||||
log,
|
||||
'-WORK',
|
||||
session.path,
|
||||
...additionalArgs,
|
||||
process.platform === 'win32' ? '-nosplash' : ''
|
||||
]).catch((err) => ({ stderr: err, stdout: '' }))
|
||||
|
||||
if (await fileExists(log)) log = await readFile(log)
|
||||
else log = ''
|
||||
|
||||
if (await fileExists(webout)) webout = await readFile(webout)
|
||||
else webout = ''
|
||||
|
||||
const debug = Object.keys(vars).find(
|
||||
(key: string) => key.toLowerCase() === '_debug'
|
||||
)
|
||||
|
||||
let jsonResult
|
||||
if ((debug && vars[debug] >= 131) || stderr) {
|
||||
webout = `<html><body>
|
||||
${webout}
|
||||
<div style="text-align:left">
|
||||
<hr /><h2>SAS Log</h2>
|
||||
<pre>${log}</pre>
|
||||
</div>
|
||||
</body></html>`
|
||||
} else if (returnJson) {
|
||||
jsonResult = { result: webout, log: log }
|
||||
}
|
||||
|
||||
session.inUse = false
|
||||
|
||||
sessionController.deleteSession(session)
|
||||
|
||||
return Promise.resolve(jsonResult || webout)
|
||||
}
|
||||
|
||||
buildDirectorytree() {
|
||||
const root: TreeNode = {
|
||||
name: 'files',
|
||||
relativePath: '',
|
||||
absolutePath: getTmpFilesFolderPath(),
|
||||
children: []
|
||||
}
|
||||
|
||||
const stack = [root]
|
||||
|
||||
while (stack.length) {
|
||||
const currentNode = stack.pop()
|
||||
|
||||
if (currentNode) {
|
||||
const children = fs.readdirSync(currentNode.absolutePath)
|
||||
|
||||
for (let child of children) {
|
||||
const absoluteChildPath = `${currentNode.absolutePath}/${child}`
|
||||
const relativeChildPath = `${currentNode.relativePath}/${child}`
|
||||
const childNode: TreeNode = {
|
||||
name: child,
|
||||
relativePath: relativeChildPath,
|
||||
absolutePath: absoluteChildPath,
|
||||
children: []
|
||||
}
|
||||
currentNode.children.push(childNode)
|
||||
|
||||
if (fs.statSync(childNode.absolutePath).isDirectory()) {
|
||||
stack.push(childNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
}
|
||||
36
api/src/controllers/FileUploadController.ts
Normal file
36
api/src/controllers/FileUploadController.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { uuidv4 } from '@sasjs/utils'
|
||||
import { getSessionController } from '.'
|
||||
const multer = require('multer')
|
||||
|
||||
export class FileUploadController {
|
||||
private storage = multer.diskStorage({
|
||||
destination: function (req: any, file: any, cb: any) {
|
||||
//Sending the intercepted files to the sessions subfolder
|
||||
cb(null, req.sasSession.path)
|
||||
},
|
||||
filename: function (req: any, file: any, cb: any) {
|
||||
//req_file prefix + unique hash added to sas request files
|
||||
cb(null, `req_file_${uuidv4().replace(/-/gm, '')}`)
|
||||
}
|
||||
})
|
||||
|
||||
private upload = multer({ storage: this.storage })
|
||||
|
||||
//It will intercept request and generate unique uuid to be used as a subfolder name
|
||||
//that will store the files uploaded
|
||||
public preuploadMiddleware = async (req: any, res: any, next: any) => {
|
||||
let session
|
||||
|
||||
const sessionController = getSessionController()
|
||||
session = await sessionController.getSession()
|
||||
session.inUse = true
|
||||
|
||||
req.sasSession = session
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
public getMulterUploadObject() {
|
||||
return this.upload
|
||||
}
|
||||
}
|
||||
127
api/src/controllers/Session.ts
Normal file
127
api/src/controllers/Session.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Session } from '../types'
|
||||
import { getTmpSessionsFolderPath, generateUniqueFileName } from '../utils'
|
||||
import {
|
||||
deleteFolder,
|
||||
createFile,
|
||||
fileExists,
|
||||
deleteFile,
|
||||
generateTimestamp
|
||||
} from '@sasjs/utils'
|
||||
import path from 'path'
|
||||
import { ExecutionController } from './Execution'
|
||||
|
||||
export class SessionController {
|
||||
private sessions: Session[] = []
|
||||
private executionController: ExecutionController
|
||||
|
||||
constructor() {
|
||||
this.executionController = new ExecutionController()
|
||||
}
|
||||
|
||||
public async getSession() {
|
||||
const readySessions = this.sessions.filter((sess: Session) => sess.ready)
|
||||
|
||||
const session = readySessions.length
|
||||
? readySessions[0]
|
||||
: await this.createSession()
|
||||
|
||||
if (readySessions.length < 2) this.createSession()
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
private async createSession() {
|
||||
const sessionId = generateUniqueFileName(generateTimestamp())
|
||||
const sessionFolder = path.join(await getTmpSessionsFolderPath(), sessionId)
|
||||
|
||||
const autoExecContent = `data _null_;
|
||||
/* remove the dummy SYSIN */
|
||||
length fname $8;
|
||||
rc=filename(fname,getoption('SYSIN') );
|
||||
if rc = 0 and fexist(fname) then rc=fdelete(fname);
|
||||
rc=filename(fname);
|
||||
/* now wait for the real SYSIN */
|
||||
slept=0;
|
||||
do until ( fileexist(getoption('SYSIN')) or slept>(60*15) );
|
||||
slept=slept+sleep(0.01,1);
|
||||
end;
|
||||
run;
|
||||
EOL`
|
||||
|
||||
const autoExec = path.join(sessionFolder, 'autoexec.sas')
|
||||
await createFile(autoExec, autoExecContent)
|
||||
|
||||
await createFile(path.join(sessionFolder, 'code.sas'), '')
|
||||
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
creationTimeStamp: creationTimeStamp,
|
||||
deathTimeStamp: (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString(),
|
||||
path: sessionFolder,
|
||||
inUse: false
|
||||
}
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
|
||||
this.executionController.execute('', autoExec, session).catch(() => {})
|
||||
|
||||
this.sessions.push(session)
|
||||
|
||||
await this.waitForSession(session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public async waitForSession(session: Session) {
|
||||
if (await fileExists(path.join(session.path, 'code.sas'))) {
|
||||
while (await fileExists(path.join(session.path, 'code.sas'))) {}
|
||||
|
||||
await deleteFile(path.join(session.path, 'log.log'))
|
||||
|
||||
session.ready = true
|
||||
|
||||
return Promise.resolve(session)
|
||||
} else {
|
||||
session.ready = true
|
||||
|
||||
return Promise.resolve(session)
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteSession(session: Session) {
|
||||
await deleteFolder(session.path)
|
||||
|
||||
if (session.ready) {
|
||||
this.sessions = this.sessions.filter(
|
||||
(sess: Session) => sess.id !== session.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSessionDestroy(session: Session) {
|
||||
setTimeout(async () => {
|
||||
if (session.inUse) {
|
||||
session.deathTimeStamp = session.deathTimeStamp + 1000 * 10
|
||||
|
||||
this.scheduleSessionDestroy(session)
|
||||
} else {
|
||||
await this.deleteSession(session)
|
||||
}
|
||||
}, parseInt(session.deathTimeStamp) - new Date().getTime() - 100)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionController = () => {
|
||||
if (process.sessionController) return process.sessionController
|
||||
|
||||
process.sessionController = new SessionController()
|
||||
|
||||
return process.sessionController
|
||||
}
|
||||
221
api/src/controllers/auth.ts
Normal file
221
api/src/controllers/auth.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Security, Route, Tags, Example, Post, Body, Query, Hidden } from 'tsoa'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import User from '../model/User'
|
||||
import { InfoJWT } from '../types'
|
||||
import { removeTokensInDB, saveTokensInDB } from '../utils'
|
||||
|
||||
@Route('SASjsApi/auth')
|
||||
@Tags('Auth')
|
||||
export default class AuthController {
|
||||
static authCodes: { [key: string]: { [key: string]: string } } = {}
|
||||
static saveCode = (userId: number, clientId: string, code: string) => {
|
||||
if (AuthController.authCodes[userId])
|
||||
return (AuthController.authCodes[userId][clientId] = code)
|
||||
|
||||
AuthController.authCodes[userId] = { [clientId]: code }
|
||||
return AuthController.authCodes[userId][clientId]
|
||||
}
|
||||
static deleteCode = (userId: number, clientId: string) =>
|
||||
delete AuthController.authCodes[userId][clientId]
|
||||
|
||||
/**
|
||||
* Accept a valid username/password, plus a CLIENT_ID, and return an AUTH_CODE
|
||||
*
|
||||
*/
|
||||
@Example<AuthorizeResponse>({
|
||||
code: 'someRandomCryptoString'
|
||||
})
|
||||
@Post('/authorize')
|
||||
public async authorize(
|
||||
@Body() body: AuthorizePayload
|
||||
): Promise<AuthorizeResponse> {
|
||||
return authorize(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts client/auth code and returns access/refresh tokens
|
||||
*
|
||||
*/
|
||||
@Example<TokenResponse>({
|
||||
accessToken: 'someRandomCryptoString',
|
||||
refreshToken: 'someRandomCryptoString'
|
||||
})
|
||||
@Post('/token')
|
||||
public async token(@Body() body: TokenPayload): Promise<TokenResponse> {
|
||||
return token(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns new access/refresh tokens
|
||||
*
|
||||
*/
|
||||
@Example<TokenResponse>({
|
||||
accessToken: 'someRandomCryptoString',
|
||||
refreshToken: 'someRandomCryptoString'
|
||||
})
|
||||
@Security('bearerAuth')
|
||||
@Post('/refresh')
|
||||
public async refresh(
|
||||
@Query() @Hidden() data?: InfoJWT
|
||||
): Promise<TokenResponse> {
|
||||
return refresh(data!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout terminate access/refresh tokens and returns nothing
|
||||
*
|
||||
*/
|
||||
@Security('bearerAuth')
|
||||
@Post('/logout')
|
||||
public async logout(@Query() @Hidden() data?: InfoJWT) {
|
||||
return logout(data!)
|
||||
}
|
||||
}
|
||||
|
||||
const authorize = async (data: any): Promise<AuthorizeResponse> => {
|
||||
const { username, password, clientId } = data
|
||||
|
||||
// Authenticate User
|
||||
const user = await User.findOne({ username })
|
||||
if (!user) throw new Error('Username is not found.')
|
||||
|
||||
const validPass = user.comparePassword(password)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
|
||||
// generate authorization code against clientId
|
||||
const userInfo: InfoJWT = {
|
||||
clientId,
|
||||
userId: user.id
|
||||
}
|
||||
const code = AuthController.saveCode(
|
||||
user.id,
|
||||
clientId,
|
||||
generateAuthCode(userInfo)
|
||||
)
|
||||
|
||||
return { code }
|
||||
}
|
||||
|
||||
const token = async (data: any): Promise<TokenResponse> => {
|
||||
const { clientId, code } = data
|
||||
|
||||
const userInfo = await verifyAuthCode(clientId, code)
|
||||
if (!userInfo) throw new Error('Invalid Auth Code')
|
||||
|
||||
if (AuthController.authCodes[userInfo.userId][clientId] !== code)
|
||||
throw new Error('Invalid Auth Code')
|
||||
|
||||
AuthController.deleteCode(userInfo.userId, clientId)
|
||||
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
|
||||
await saveTokensInDB(userInfo.userId, clientId, accessToken, refreshToken)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
const refresh = async (userInfo: InfoJWT): Promise<TokenResponse> => {
|
||||
const accessToken = generateAccessToken(userInfo)
|
||||
const refreshToken = generateRefreshToken(userInfo)
|
||||
|
||||
await saveTokensInDB(
|
||||
userInfo.userId,
|
||||
userInfo.clientId,
|
||||
accessToken,
|
||||
refreshToken
|
||||
)
|
||||
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
|
||||
const logout = async (userInfo: InfoJWT) => {
|
||||
await removeTokensInDB(userInfo.userId, userInfo.clientId)
|
||||
}
|
||||
|
||||
interface AuthorizePayload {
|
||||
/**
|
||||
* Username for user
|
||||
* @example "secretuser"
|
||||
*/
|
||||
username: string
|
||||
/**
|
||||
* Password for user
|
||||
* @example "secretpassword"
|
||||
*/
|
||||
password: string
|
||||
/**
|
||||
* Client ID
|
||||
* @example "clientID1"
|
||||
*/
|
||||
clientId: string
|
||||
}
|
||||
|
||||
interface AuthorizeResponse {
|
||||
/**
|
||||
* Authorization code
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
code: string
|
||||
}
|
||||
|
||||
interface TokenPayload {
|
||||
/**
|
||||
* Client ID
|
||||
* @example "clientID1"
|
||||
*/
|
||||
clientId: string
|
||||
/**
|
||||
* Authorization code
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
code: string
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
/**
|
||||
* Access Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
accessToken: string
|
||||
/**
|
||||
* Refresh Token
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
export const generateAuthCode = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
|
||||
expiresIn: '30s'
|
||||
})
|
||||
|
||||
export const generateAccessToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
|
||||
expiresIn: '1h'
|
||||
})
|
||||
|
||||
export const generateRefreshToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
|
||||
expiresIn: '1day'
|
||||
})
|
||||
|
||||
const verifyAuthCode = async (
|
||||
clientId: string,
|
||||
code: string
|
||||
): Promise<InfoJWT | undefined> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(code, process.env.AUTH_CODE_SECRET as string, (err, data) => {
|
||||
if (err) return resolve(undefined)
|
||||
|
||||
const clientInfo: InfoJWT = {
|
||||
clientId: data?.clientId,
|
||||
userId: data?.userId
|
||||
}
|
||||
if (clientInfo.clientId === clientId) {
|
||||
return resolve(clientInfo)
|
||||
}
|
||||
return resolve(undefined)
|
||||
})
|
||||
})
|
||||
}
|
||||
44
api/src/controllers/client.ts
Normal file
44
api/src/controllers/client.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Security, Route, Tags, Example, Post, Body } from 'tsoa'
|
||||
|
||||
import Client, { ClientPayload } from '../model/Client'
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/client')
|
||||
@Tags('Client')
|
||||
export default class ClientController {
|
||||
/**
|
||||
* Create client with the following attributes: ClientId, ClientSecret. Admin only task.
|
||||
*
|
||||
*/
|
||||
@Example<ClientPayload>({
|
||||
clientId: 'someFormattedClientID1234',
|
||||
clientSecret: 'someRandomCryptoString'
|
||||
})
|
||||
@Post('/')
|
||||
public async createClient(
|
||||
@Body() body: ClientPayload
|
||||
): Promise<ClientPayload> {
|
||||
return createClient(body)
|
||||
}
|
||||
}
|
||||
|
||||
const createClient = async (data: any): Promise<ClientPayload> => {
|
||||
const { clientId, clientSecret } = data
|
||||
|
||||
// Checking if client is already in the database
|
||||
const clientExist = await Client.findOne({ clientId })
|
||||
if (clientExist) throw new Error('Client ID already exists.')
|
||||
|
||||
// Create a new client
|
||||
const client = new Client({
|
||||
clientId,
|
||||
clientSecret
|
||||
})
|
||||
|
||||
const savedClient = await client.save()
|
||||
|
||||
return {
|
||||
clientId: savedClient.clientId,
|
||||
clientSecret: savedClient.clientSecret
|
||||
}
|
||||
}
|
||||
59
api/src/controllers/deploy.ts
Normal file
59
api/src/controllers/deploy.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { MemberType, FolderMember, ServiceMember, FileTree } from '../types'
|
||||
import { getTmpFilesFolderPath } from '../utils/file'
|
||||
import { createFolder, createFile, asyncForEach } from '@sasjs/utils'
|
||||
import path from 'path'
|
||||
|
||||
// REFACTOR: export FileTreeCpntroller
|
||||
export const createFileTree = async (
|
||||
members: (FolderMember | ServiceMember)[],
|
||||
parentFolders: string[] = []
|
||||
) => {
|
||||
const destinationPath = path.join(
|
||||
getTmpFilesFolderPath(),
|
||||
path.join(...parentFolders)
|
||||
)
|
||||
|
||||
await asyncForEach(members, async (member: FolderMember | ServiceMember) => {
|
||||
let name = member.name
|
||||
|
||||
if (member.type === MemberType.service) name += '.sas'
|
||||
|
||||
if (member.type === MemberType.folder) {
|
||||
await createFolder(path.join(destinationPath, name)).catch((err) =>
|
||||
Promise.reject({ error: err, failedToCreate: name })
|
||||
)
|
||||
|
||||
await createFileTree(member.members, [...parentFolders, name]).catch(
|
||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
||||
)
|
||||
} else {
|
||||
await createFile(path.join(destinationPath, name), member.code).catch(
|
||||
(err) => Promise.reject({ error: err, failedToCreate: name })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export const getTreeExample = (): FileTree => ({
|
||||
members: [
|
||||
{
|
||||
name: 'jobs',
|
||||
type: MemberType.folder,
|
||||
members: [
|
||||
{
|
||||
name: 'extract',
|
||||
type: MemberType.folder,
|
||||
members: [
|
||||
{
|
||||
name: 'makedata1',
|
||||
type: MemberType.service,
|
||||
code: '%put Hello World!;'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
230
api/src/controllers/group.ts
Normal file
230
api/src/controllers/group.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Example,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body
|
||||
} from 'tsoa'
|
||||
|
||||
import Group, { GroupPayload } from '../model/Group'
|
||||
import User from '../model/User'
|
||||
import { UserResponse } from './user'
|
||||
|
||||
interface GroupResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface GroupDetailsResponse {
|
||||
groupId: number
|
||||
name: string
|
||||
description: string
|
||||
isActive: boolean
|
||||
users: UserResponse[]
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/group')
|
||||
@Tags('Group')
|
||||
export default class GroupController {
|
||||
/**
|
||||
* Get list of all groups (groupName and groupDescription). All users can request this.
|
||||
*
|
||||
*/
|
||||
@Example<GroupResponse[]>([
|
||||
{
|
||||
groupId: 123,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users'
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllGroups(): Promise<GroupResponse[]> {
|
||||
return getAllGroups()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group. Admin only.
|
||||
*
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Post('/')
|
||||
public async createGroup(
|
||||
@Body() body: GroupPayload
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return createGroup(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of members of a group (userName). All users can request this.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
*/
|
||||
@Get('{groupId}')
|
||||
public async getGroup(
|
||||
@Path() groupId: number
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return getGroup(groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Post('{groupId}/{userId}')
|
||||
public async addUserToGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return addUserToGroup(groupId, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user to a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId "1234"
|
||||
* @param userId The user's identifier
|
||||
* @example userId "6789"
|
||||
*/
|
||||
@Example<GroupDetailsResponse>({
|
||||
groupId: 123,
|
||||
name: 'DCGroup',
|
||||
description: 'This group represents Data Controller Users',
|
||||
isActive: true,
|
||||
users: []
|
||||
})
|
||||
@Delete('{groupId}/{userId}')
|
||||
public async removeUserFromGroup(
|
||||
@Path() groupId: number,
|
||||
@Path() userId: number
|
||||
): Promise<GroupDetailsResponse> {
|
||||
return removeUserFromGroup(groupId, userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group. Admin task only.
|
||||
* @param groupId The group's identifier
|
||||
* @example groupId 1234
|
||||
*/
|
||||
@Delete('{groupId}')
|
||||
public async deleteGroup(@Path() groupId: number) {
|
||||
const { deletedCount } = await Group.deleteOne({ groupId })
|
||||
if (deletedCount) return
|
||||
throw new Error('No Group deleted!')
|
||||
}
|
||||
}
|
||||
|
||||
const getAllGroups = async (): Promise<GroupResponse[]> =>
|
||||
await Group.find({})
|
||||
.select({ _id: 0, groupId: 1, name: 1, description: 1 })
|
||||
.exec()
|
||||
|
||||
const createGroup = async ({
|
||||
name,
|
||||
description,
|
||||
isActive
|
||||
}: GroupPayload): Promise<GroupDetailsResponse> => {
|
||||
const group = new Group({
|
||||
name,
|
||||
description,
|
||||
isActive
|
||||
})
|
||||
|
||||
const savedGroup = await group.save()
|
||||
|
||||
return {
|
||||
groupId: savedGroup.groupId,
|
||||
name: savedGroup.name,
|
||||
description: savedGroup.description,
|
||||
isActive: savedGroup.isActive,
|
||||
users: []
|
||||
}
|
||||
}
|
||||
|
||||
const getGroup = async (groupId: number): Promise<GroupDetailsResponse> => {
|
||||
const group = (await Group.findOne(
|
||||
{ groupId },
|
||||
'groupId name description isActive users -_id'
|
||||
).populate(
|
||||
'users',
|
||||
'id username displayName -_id'
|
||||
)) as unknown as GroupDetailsResponse
|
||||
if (!group) throw new Error('Group is not found.')
|
||||
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
isActive: group.isActive,
|
||||
users: group.users
|
||||
}
|
||||
}
|
||||
|
||||
const addUserToGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
): Promise<GroupDetailsResponse> => {
|
||||
const group = await Group.findOne({ groupId })
|
||||
if (!group) throw new Error('Group not found')
|
||||
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user) throw new Error('User not found')
|
||||
|
||||
const updatedGroup = (await group.addUser(
|
||||
user._id
|
||||
)) as unknown as GroupDetailsResponse
|
||||
if (!updatedGroup) throw new Error('Unable to update group')
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description,
|
||||
isActive: updatedGroup.isActive,
|
||||
users: updatedGroup.users
|
||||
}
|
||||
}
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
): Promise<GroupDetailsResponse> => {
|
||||
const group = await Group.findOne({ groupId })
|
||||
if (!group) throw new Error('Group not found')
|
||||
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user) throw new Error('User not found')
|
||||
|
||||
const updatedGroup = (await group.removeUser(
|
||||
user._id
|
||||
)) as unknown as GroupDetailsResponse
|
||||
if (!updatedGroup) throw new Error('Unable to update group')
|
||||
|
||||
return {
|
||||
groupId: updatedGroup.groupId,
|
||||
name: updatedGroup.name,
|
||||
description: updatedGroup.description,
|
||||
isActive: updatedGroup.isActive,
|
||||
users: updatedGroup.users
|
||||
}
|
||||
}
|
||||
5
api/src/controllers/index.ts
Normal file
5
api/src/controllers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './deploy'
|
||||
export * from './drive'
|
||||
export * from './Session'
|
||||
export * from './Execution'
|
||||
export * from './FileUploadController'
|
||||
220
api/src/controllers/user.ts
Normal file
220
api/src/controllers/user.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Path,
|
||||
Query,
|
||||
Example,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Hidden
|
||||
} from 'tsoa'
|
||||
|
||||
import User, { UserPayload } from '../model/User'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface UserDetailsResponse {
|
||||
id: number
|
||||
displayName: string
|
||||
username: string
|
||||
isActive: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/user')
|
||||
@Tags('User')
|
||||
export default class UserController {
|
||||
/**
|
||||
* Get list of all users (username, displayname). All users can request this.
|
||||
*
|
||||
*/
|
||||
@Example<UserResponse[]>([
|
||||
{
|
||||
id: 123,
|
||||
username: 'johnusername',
|
||||
displayName: 'John'
|
||||
},
|
||||
{
|
||||
id: 456,
|
||||
username: 'starkusername',
|
||||
displayName: 'Stark'
|
||||
}
|
||||
])
|
||||
@Get('/')
|
||||
public async getAllUsers(): Promise<UserResponse[]> {
|
||||
return getAllUsers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user with the following attributes: UserId, UserName, Password, isAdmin, isActive. Admin only task.
|
||||
*
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
})
|
||||
@Post('/')
|
||||
public async createUser(
|
||||
@Body() body: UserPayload
|
||||
): Promise<UserDetailsResponse> {
|
||||
return createUser(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user properties - such as group memberships, userName, displayName.
|
||||
* @param userId The user's identifier
|
||||
* @example userId 1234
|
||||
*/
|
||||
@Get('{userId}')
|
||||
public async getUser(@Path() userId: number): Promise<UserDetailsResponse> {
|
||||
return getUser(userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user properties - such as displayName. Can be performed either by admins, or the user in question.
|
||||
* @param userId The user's identifier
|
||||
* @example userId "1234"
|
||||
*/
|
||||
@Example<UserDetailsResponse>({
|
||||
id: 1234,
|
||||
displayName: 'John Snow',
|
||||
username: 'johnSnow01',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
})
|
||||
@Patch('{userId}')
|
||||
public async updateUser(
|
||||
@Path() userId: number,
|
||||
@Body() body: UserPayload
|
||||
): Promise<UserDetailsResponse> {
|
||||
return updateUser(userId, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user. Can be performed either by admins, or the user in question.
|
||||
* @param userId The user's identifier
|
||||
* @example userId 1234
|
||||
*/
|
||||
@Delete('{userId}')
|
||||
public async deleteUser(
|
||||
@Path() userId: number,
|
||||
@Body() body: { password?: string },
|
||||
@Query() @Hidden() isAdmin: boolean = false
|
||||
) {
|
||||
return deleteUser(userId, isAdmin, body)
|
||||
}
|
||||
}
|
||||
|
||||
const getAllUsers = async (): Promise<UserResponse[]> =>
|
||||
await User.find({})
|
||||
.select({ _id: 0, id: 1, username: 1, displayName: 1 })
|
||||
.exec()
|
||||
|
||||
const createUser = async (data: UserPayload): Promise<UserDetailsResponse> => {
|
||||
const { displayName, username, password, isAdmin, isActive } = data
|
||||
|
||||
// Checking if user is already in the database
|
||||
const usernameExist = await User.findOne({ username })
|
||||
if (usernameExist) throw new Error('Username already exists.')
|
||||
|
||||
// Hash passwords
|
||||
const hashPassword = User.hashPassword(password)
|
||||
|
||||
// Create a new user
|
||||
const user = new User({
|
||||
displayName,
|
||||
username,
|
||||
password: hashPassword,
|
||||
isAdmin,
|
||||
isActive
|
||||
})
|
||||
|
||||
const savedUser = await user.save()
|
||||
|
||||
return {
|
||||
id: savedUser.id,
|
||||
displayName: savedUser.displayName,
|
||||
username: savedUser.username,
|
||||
isActive: savedUser.isActive,
|
||||
isAdmin: savedUser.isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
const getUser = async (id: number): Promise<UserDetailsResponse> => {
|
||||
const user = await User.findOne({ id })
|
||||
.select({
|
||||
_id: 0,
|
||||
id: 1,
|
||||
username: 1,
|
||||
displayName: 1,
|
||||
isAdmin: 1,
|
||||
isActive: 1
|
||||
})
|
||||
.exec()
|
||||
if (!user) throw new Error('User is not found.')
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
const updateUser = async (
|
||||
id: number,
|
||||
data: UserPayload
|
||||
): Promise<UserDetailsResponse> => {
|
||||
const { displayName, username, password, isAdmin, isActive } = data
|
||||
|
||||
const params: any = { displayName, isAdmin, isActive }
|
||||
|
||||
if (username) {
|
||||
// Checking if user is already in the database
|
||||
const usernameExist = await User.findOne({ username })
|
||||
if (usernameExist?.id != id) throw new Error('Username already exists.')
|
||||
params.username = username
|
||||
}
|
||||
|
||||
if (password) {
|
||||
// Hash passwords
|
||||
params.password = User.hashPassword(password)
|
||||
}
|
||||
|
||||
const updatedUser = await User.findOneAndUpdate({ id }, params, { new: true })
|
||||
.select({
|
||||
_id: 0,
|
||||
id: 1,
|
||||
username: 1,
|
||||
displayName: 1,
|
||||
isAdmin: 1,
|
||||
isActive: 1
|
||||
})
|
||||
.exec()
|
||||
if (!updatedUser) throw new Error('Unable to update user')
|
||||
|
||||
return updatedUser
|
||||
}
|
||||
|
||||
const deleteUser = async (
|
||||
id: number,
|
||||
isAdmin: boolean,
|
||||
{ password }: { password?: string }
|
||||
) => {
|
||||
const user = await User.findOne({ id })
|
||||
if (!user) throw new Error('User is not found.')
|
||||
|
||||
if (!isAdmin) {
|
||||
const validPass = user.comparePassword(password!)
|
||||
if (!validPass) throw new Error('Invalid password.')
|
||||
}
|
||||
|
||||
await User.deleteOne({ id })
|
||||
}
|
||||
54
api/src/middlewares/authenticateToken.ts
Normal file
54
api/src/middlewares/authenticateToken.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { verifyTokenInDB } from '../utils'
|
||||
|
||||
export const authenticateAccessToken = (req: any, res: any, next: any) => {
|
||||
authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.ACCESS_TOKEN_SECRET as string,
|
||||
'accessToken'
|
||||
)
|
||||
}
|
||||
|
||||
export const authenticateRefreshToken = (req: any, res: any, next: any) => {
|
||||
authenticateToken(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
process.env.REFRESH_TOKEN_SECRET as string,
|
||||
'refreshToken'
|
||||
)
|
||||
}
|
||||
|
||||
const authenticateToken = (
|
||||
req: any,
|
||||
res: any,
|
||||
next: any,
|
||||
key: string,
|
||||
tokenType: 'accessToken' | 'refreshToken' = 'accessToken'
|
||||
) => {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader?.split(' ')[1]
|
||||
if (!token) return res.sendStatus(401)
|
||||
|
||||
jwt.verify(token, key, async (err: any, data: any) => {
|
||||
if (err) return res.sendStatus(401)
|
||||
|
||||
// verify this valid token's entry in DB
|
||||
const user = await verifyTokenInDB(
|
||||
data?.userId,
|
||||
data?.clientId,
|
||||
token,
|
||||
tokenType
|
||||
)
|
||||
|
||||
if (user) {
|
||||
if (user.isActive) {
|
||||
req.user = user
|
||||
return next()
|
||||
} else return res.sendStatus(401)
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
})
|
||||
}
|
||||
3
api/src/middlewares/index.ts
Normal file
3
api/src/middlewares/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './authenticateToken'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
5
api/src/middlewares/verifyAdmin.ts
Normal file
5
api/src/middlewares/verifyAdmin.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const verifyAdmin = (req: any, res: any, next: any) => {
|
||||
const { user } = req
|
||||
if (!user?.isAdmin) return res.status(401).send('Admin account required')
|
||||
next()
|
||||
}
|
||||
9
api/src/middlewares/verifyAdminIfNeeded.ts
Normal file
9
api/src/middlewares/verifyAdminIfNeeded.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const verifyAdminIfNeeded = (req: any, res: any, next: any) => {
|
||||
const { user } = req
|
||||
const userId = parseInt(req.params.userId)
|
||||
|
||||
if (!user.isAdmin && user.userId !== userId) {
|
||||
return res.status(401).send('Admin account required')
|
||||
}
|
||||
next()
|
||||
}
|
||||
27
api/src/model/Client.ts
Normal file
27
api/src/model/Client.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import mongoose, { Schema } from 'mongoose'
|
||||
|
||||
export interface ClientPayload {
|
||||
/**
|
||||
* Client ID
|
||||
* @example "someFormattedClientID1234"
|
||||
*/
|
||||
clientId: string
|
||||
/**
|
||||
* Client Secret
|
||||
* @example "someRandomCryptoString"
|
||||
*/
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
const ClientSchema = new Schema<ClientPayload>({
|
||||
clientId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
clientSecret: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
export default mongoose.model('Client', ClientSchema)
|
||||
87
api/src/model/Group.ts
Normal file
87
api/src/model/Group.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
|
||||
export interface GroupPayload {
|
||||
/**
|
||||
* Name of the group
|
||||
* @example "DCGroup"
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* Description of the group
|
||||
* @example "This group represents Data Controller Users"
|
||||
*/
|
||||
description: string
|
||||
/**
|
||||
* Group should be active or not, defaults to true
|
||||
* @example "true"
|
||||
*/
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface IGroupDocument extends GroupPayload, Document {
|
||||
groupId: number
|
||||
isActive: boolean
|
||||
users: Schema.Types.ObjectId[]
|
||||
}
|
||||
|
||||
interface IGroup extends IGroupDocument {
|
||||
addUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
removeUser(userObjectId: Schema.Types.ObjectId): Promise<IGroup>
|
||||
}
|
||||
interface IGroupModel extends Model<IGroup> {}
|
||||
|
||||
const groupSchema = new Schema<IGroupDocument>({
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Group description.'
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
users: [{ type: Schema.Types.ObjectId, ref: 'User' }]
|
||||
})
|
||||
groupSchema.plugin(AutoIncrement, { inc_field: 'groupId' })
|
||||
|
||||
// Hooks
|
||||
groupSchema.post('save', function (group: IGroup, next: Function) {
|
||||
group.populate('users', 'id username displayName -_id').then(function () {
|
||||
next()
|
||||
})
|
||||
})
|
||||
|
||||
// Instance Methods
|
||||
groupSchema.method(
|
||||
'addUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex === -1) {
|
||||
this.users.push(userObjectId)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
}
|
||||
)
|
||||
groupSchema.method(
|
||||
'removeUser',
|
||||
async function (userObjectId: Schema.Types.ObjectId) {
|
||||
const userIdIndex = this.users.indexOf(userObjectId)
|
||||
if (userIdIndex > -1) {
|
||||
this.users.splice(userIdIndex, 1)
|
||||
}
|
||||
this.markModified('users')
|
||||
return this.save()
|
||||
}
|
||||
)
|
||||
|
||||
export const Group: IGroupModel = model<IGroup, IGroupModel>(
|
||||
'Group',
|
||||
groupSchema
|
||||
)
|
||||
|
||||
export default Group
|
||||
103
api/src/model/User.ts
Normal file
103
api/src/model/User.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import mongoose, { Schema, model, Document, Model } from 'mongoose'
|
||||
const AutoIncrement = require('mongoose-sequence')(mongoose)
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export interface UserPayload {
|
||||
/**
|
||||
* Display name for user
|
||||
* @example "John Snow"
|
||||
*/
|
||||
displayName: string
|
||||
/**
|
||||
* Username for user
|
||||
* @example "johnSnow01"
|
||||
*/
|
||||
username: string
|
||||
/**
|
||||
* Password for user
|
||||
*/
|
||||
password: string
|
||||
/**
|
||||
* Account should be admin or not, defaults to false
|
||||
* @example "false"
|
||||
*/
|
||||
isAdmin?: boolean
|
||||
/**
|
||||
* Account should be active or not, defaults to true
|
||||
* @example "true"
|
||||
*/
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
interface IUserDocument extends UserPayload, Document {
|
||||
id: number
|
||||
isAdmin: boolean
|
||||
isActive: boolean
|
||||
groups: Schema.Types.ObjectId[]
|
||||
tokens: [{ [key: string]: string }]
|
||||
}
|
||||
|
||||
interface IUser extends IUserDocument {
|
||||
comparePassword(password: string): boolean
|
||||
}
|
||||
interface IUserModel extends Model<IUser> {
|
||||
hashPassword(password: string): string
|
||||
}
|
||||
|
||||
const userSchema = new Schema<IUserDocument>({
|
||||
displayName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
groups: [{ type: Schema.Types.ObjectId, ref: 'Group' }],
|
||||
tokens: [
|
||||
{
|
||||
clientId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
accessToken: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
refreshToken: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
userSchema.plugin(AutoIncrement, { inc_field: 'id' })
|
||||
|
||||
// Static Methods
|
||||
userSchema.static('hashPassword', (password: string): string => {
|
||||
const salt = bcrypt.genSaltSync(10)
|
||||
return bcrypt.hashSync(password, salt)
|
||||
})
|
||||
|
||||
// Instance Methods
|
||||
userSchema.method('comparePassword', function (password: string): boolean {
|
||||
if (bcrypt.compareSync(password, this.password)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
export const User: IUserModel = model<IUser, IUserModel>('User', userSchema)
|
||||
|
||||
export default User
|
||||
19
api/src/prod-server.ts
Normal file
19
api/src/prod-server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import * as https from 'https'
|
||||
import { configuration } from '../package.json'
|
||||
import app from './app'
|
||||
|
||||
const keyPath = path.join('certificates', 'privkey.pem')
|
||||
const certPath = path.join('certificates', 'fullchain.pem')
|
||||
|
||||
const key = readFileSync(keyPath)
|
||||
const cert = readFileSync(certPath)
|
||||
|
||||
const httpsServer = https.createServer({ key, cert }, app)
|
||||
|
||||
httpsServer.listen(configuration.sasJsPort, () => {
|
||||
console.log(
|
||||
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
|
||||
)
|
||||
})
|
||||
100
api/src/routes/api/auth.ts
Normal file
100
api/src/routes/api/auth.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import express from 'express'
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
import AuthController from '../../controllers/auth'
|
||||
import Client from '../../model/Client'
|
||||
|
||||
import {
|
||||
authenticateAccessToken,
|
||||
authenticateRefreshToken
|
||||
} from '../../middlewares'
|
||||
|
||||
import { authorizeValidation, tokenValidation } from '../../utils'
|
||||
import { InfoJWT } from '../../types'
|
||||
|
||||
const authRouter = express.Router()
|
||||
|
||||
const clientIDs = new Set()
|
||||
|
||||
export const populateClients = async () => {
|
||||
const result = await Client.find()
|
||||
clientIDs.clear()
|
||||
result.forEach((r) => {
|
||||
clientIDs.add(r.clientId)
|
||||
})
|
||||
}
|
||||
|
||||
export const connectDB = () => {
|
||||
// NOTE: when exporting app.js as agent for supertest
|
||||
// we should exlcude connecting to the real database
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
mongoose.connect(process.env.DB_CONNECT as string, async (err) => {
|
||||
if (err) throw err
|
||||
|
||||
console.log('Connected to db!')
|
||||
|
||||
await populateClients()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
authRouter.post('/authorize', async (req, res) => {
|
||||
const { error, value: body } = authorizeValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const { clientId } = body
|
||||
|
||||
// Verify client ID
|
||||
if (!clientIDs.has(clientId)) {
|
||||
return res.status(403).send('Invalid clientId.')
|
||||
}
|
||||
|
||||
const controller = new AuthController()
|
||||
try {
|
||||
const response = await controller.authorize(body)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
authRouter.post('/token', async (req, res) => {
|
||||
const { error, value: body } = tokenValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new AuthController()
|
||||
try {
|
||||
const response = await controller.token(body)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
authRouter.post('/refresh', authenticateRefreshToken, async (req: any, res) => {
|
||||
const userInfo: InfoJWT = req.user
|
||||
|
||||
const controller = new AuthController()
|
||||
try {
|
||||
const response = await controller.refresh(userInfo)
|
||||
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
authRouter.delete('/logout', authenticateAccessToken, async (req: any, res) => {
|
||||
const userInfo: InfoJWT = req.user
|
||||
|
||||
const controller = new AuthController()
|
||||
try {
|
||||
await controller.logout(userInfo)
|
||||
} catch (e) {}
|
||||
|
||||
res.sendStatus(204)
|
||||
})
|
||||
|
||||
export default authRouter
|
||||
20
api/src/routes/api/client.ts
Normal file
20
api/src/routes/api/client.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express'
|
||||
import ClientController from '../../controllers/client'
|
||||
import { registerClientValidation } from '../../utils'
|
||||
|
||||
const clientRouter = express.Router()
|
||||
|
||||
clientRouter.post('/', async (req, res) => {
|
||||
const { error, value: body } = registerClientValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new ClientController()
|
||||
try {
|
||||
const response = await controller.createClient(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default clientRouter
|
||||
74
api/src/routes/api/drive.ts
Normal file
74
api/src/routes/api/drive.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import {
|
||||
DriveController,
|
||||
ExecutionController
|
||||
} from '../../controllers'
|
||||
import { isFileQuery } from '../../types'
|
||||
import { getTmpFilesFolderPath } from '../../utils'
|
||||
|
||||
const driveRouter = express.Router()
|
||||
|
||||
driveRouter.post('/deploy', async (req, res) => {
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.deploy(req.body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.get('/file', async (req, res) => {
|
||||
if (isFileQuery(req.query)) {
|
||||
const filePath = path
|
||||
.join(getTmpFilesFolderPath(), req.query.filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
await new DriveController()
|
||||
.readFile(filePath)
|
||||
.then((fileContent) => {
|
||||
res.status(200).send({ status: 'success', fileContent: fileContent })
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
...(typeof err === 'object' ? err : { details: err })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Invalid Request: Expected parameter filePath was not provided'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.patch('/file', async (req, res) => {
|
||||
const filePath = path
|
||||
.join(getTmpFilesFolderPath(), req.body.filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
await new DriveController()
|
||||
.updateFile(filePath, req.body.fileContent)
|
||||
.then(() => {
|
||||
res.status(200).send({ status: 'success' })
|
||||
})
|
||||
.catch((err) => {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
...(typeof err === 'object' ? err : { details: err })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
driveRouter.get('/fileTree', async (req, res) => {
|
||||
const tree = new ExecutionController().buildDirectorytree()
|
||||
res.status(200).send({ status: 'success', tree })
|
||||
})
|
||||
|
||||
export default driveRouter
|
||||
97
api/src/routes/api/group.ts
Normal file
97
api/src/routes/api/group.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import express from 'express'
|
||||
import GroupController from '../../controllers/group'
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
import { registerGroupValidation } from '../../utils'
|
||||
import userRouter from './user'
|
||||
|
||||
const groupRouter = express.Router()
|
||||
|
||||
groupRouter.post(
|
||||
'/',
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
async (req, res) => {
|
||||
const { error, value: body } = registerGroupValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.createGroup(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
groupRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getAllGroups()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
groupRouter.get('/:groupId', authenticateAccessToken, async (req: any, res) => {
|
||||
const { groupId } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.getGroup(groupId)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
groupRouter.post(
|
||||
'/:groupId/:userId',
|
||||
authenticateAccessToken,
|
||||
async (req: any, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.addUserToGroup(groupId, userId)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId/:userId',
|
||||
authenticateAccessToken,
|
||||
async (req: any, res) => {
|
||||
const { groupId, userId } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
const response = await controller.removeUserFromGroup(groupId, userId)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
groupRouter.delete(
|
||||
'/:groupId',
|
||||
authenticateAccessToken,
|
||||
async (req: any, res) => {
|
||||
const { groupId } = req.params
|
||||
|
||||
const controller = new GroupController()
|
||||
try {
|
||||
await controller.deleteGroup(groupId)
|
||||
res.status(200).send('Group Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default groupRouter
|
||||
35
api/src/routes/api/index.ts
Normal file
35
api/src/routes/api/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import express from 'express'
|
||||
import dotenv from 'dotenv'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
|
||||
import driveRouter from './drive'
|
||||
import stpRouter from './stp'
|
||||
import userRouter from './user'
|
||||
import groupRouter from './group'
|
||||
import clientRouter from './client'
|
||||
import authRouter, { connectDB } from './auth'
|
||||
|
||||
dotenv.config()
|
||||
connectDB()
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use('/drive', authenticateAccessToken, driveRouter)
|
||||
router.use('/stp', authenticateAccessToken, stpRouter)
|
||||
router.use('/user', userRouter)
|
||||
router.use('/group', groupRouter)
|
||||
router.use('/client', authenticateAccessToken, verifyAdmin, clientRouter)
|
||||
router.use('/auth', authRouter)
|
||||
router.use(
|
||||
'/',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(undefined, {
|
||||
swaggerOptions: {
|
||||
url: '/swagger.yaml'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
349
api/src/routes/api/spec/auth.spec.ts
Normal file
349
api/src/routes/api/spec/auth.spec.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import app from '../../../app'
|
||||
import UserController from '../../../controllers/user'
|
||||
import ClientController from '../../../controllers/client'
|
||||
import AuthController, {
|
||||
generateAccessToken,
|
||||
generateAuthCode,
|
||||
generateRefreshToken
|
||||
} from '../../../controllers/auth'
|
||||
import { populateClients } from '../auth'
|
||||
import { InfoJWT } from '../../../types'
|
||||
import { saveTokensInDB, verifyTokenInDB } from '../../../utils'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const clientSecret = 'someclientSecret'
|
||||
const user = {
|
||||
id: 1234,
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
describe('auth', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
const userController = new UserController()
|
||||
const clientController = new ClientController()
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
await clientController.createClient({ clientId, clientSecret })
|
||||
await populateClients()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('authorize', () => {
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with authorization code', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
clientId
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveProperty('code')
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if username is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
password: user.password,
|
||||
clientId
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"username" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if password is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
clientId
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"password" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if clientId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"clientId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
clientId
|
||||
})
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Username is not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if password is incorrect', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: 'WrongPassword',
|
||||
clientId
|
||||
})
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Invalid password.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if clientId is incorrect', async () => {
|
||||
await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/authorize')
|
||||
.send({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
clientId: 'WrongClientID'
|
||||
})
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Invalid clientId.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('token', () => {
|
||||
const userInfo: InfoJWT = {
|
||||
clientId,
|
||||
userId: user.id
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await userController.createUser(user)
|
||||
})
|
||||
afterAll(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with access and refresh tokens', async () => {
|
||||
const code = AuthController.saveCode(
|
||||
userInfo.userId,
|
||||
userInfo.clientId,
|
||||
generateAuthCode(userInfo)
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/token')
|
||||
.send({
|
||||
clientId,
|
||||
code
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveProperty('accessToken')
|
||||
expect(res.body).toHaveProperty('refreshToken')
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if code is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/token')
|
||||
.send({
|
||||
clientId
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"code" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if clientId is missing', async () => {
|
||||
const code = AuthController.saveCode(
|
||||
userInfo.userId,
|
||||
userInfo.clientId,
|
||||
generateAuthCode(userInfo)
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/token')
|
||||
.send({
|
||||
code
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"clientId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if code is invalid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/token')
|
||||
.send({
|
||||
clientId,
|
||||
code: 'InvalidCode'
|
||||
})
|
||||
.expect(403)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if clientId is invalid', async () => {
|
||||
const code = AuthController.saveCode(
|
||||
userInfo.userId,
|
||||
userInfo.clientId,
|
||||
generateAuthCode(userInfo)
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/token')
|
||||
.send({
|
||||
clientId: 'WrongClientID',
|
||||
code
|
||||
})
|
||||
.expect(403)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh', () => {
|
||||
let refreshToken: string
|
||||
let currentUser: any
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
refreshToken = generateRefreshToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
clientId,
|
||||
'accessToken',
|
||||
refreshToken
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with new access and refresh tokens', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/auth/refresh')
|
||||
.auth(refreshToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toHaveProperty('accessToken')
|
||||
expect(res.body).toHaveProperty('refreshToken')
|
||||
|
||||
// cannot use same refresh again
|
||||
const resWithError = await request(app)
|
||||
.post('/SASjsApi/auth/refresh')
|
||||
.auth(refreshToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(resWithError.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
let accessToken: string
|
||||
let currentUser: any
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await userController.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: currentUser.id
|
||||
})
|
||||
|
||||
await saveTokensInDB(
|
||||
currentUser.id,
|
||||
clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond no content and remove access/refresh tokens from DB', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/auth/logout')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(204)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
|
||||
expect(
|
||||
await verifyTokenInDB(
|
||||
currentUser.id,
|
||||
clientId,
|
||||
accessToken,
|
||||
'accessToken'
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
159
api/src/routes/api/spec/client.spec.ts
Normal file
159
api/src/routes/api/spec/client.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import app from '../../../app'
|
||||
import UserController from '../../../controllers/user'
|
||||
import ClientController from '../../../controllers/client'
|
||||
import { generateAccessToken } from '../../../controllers/auth'
|
||||
import { saveTokensInDB } from '../../../utils'
|
||||
|
||||
const client = {
|
||||
clientId: 'someclientID',
|
||||
clientSecret: 'someclientSecret'
|
||||
}
|
||||
const adminUser = {
|
||||
displayName: 'Test Admin',
|
||||
username: 'testAdminUsername',
|
||||
password: '12345678',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
const newClient = {
|
||||
clientId: 'newClientID',
|
||||
clientSecret: 'newClientSecret'
|
||||
}
|
||||
|
||||
describe('client', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
const userController = new UserController()
|
||||
const clientController = new ClientController()
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
let adminAccessToken: string
|
||||
let dbUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
dbUser = await userController.createUser(adminUser)
|
||||
adminAccessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
client.clientId,
|
||||
adminAccessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections
|
||||
const collection = collections['clients']
|
||||
await collection.deleteMany({})
|
||||
})
|
||||
|
||||
it('should respond with new client', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(newClient)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.clientId).toEqual(newClient.clientId)
|
||||
expect(res.body.clientSecret).toEqual(newClient.clientSecret)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.send(newClient)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbideen if access token is not of an admin account', async () => {
|
||||
const user = {
|
||||
displayName: 'User 1',
|
||||
username: 'username1',
|
||||
password: '12345678',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
const dbUser = await userController.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId: client.clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(
|
||||
dbUser.id,
|
||||
client.clientId,
|
||||
accessToken,
|
||||
'refreshToken'
|
||||
)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(newClient)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if clientId is already present', async () => {
|
||||
await clientController.createClient(newClient)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(newClient)
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Client ID already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if clientId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...newClient,
|
||||
clientId: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"clientId" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if clientSecret is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/client')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...newClient,
|
||||
clientSecret: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"clientSecret" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
155
api/src/routes/api/spec/drive.spec.ts
Normal file
155
api/src/routes/api/spec/drive.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import app from '../../../app'
|
||||
import { getTreeExample } from '../../../controllers/deploy'
|
||||
import UserController from '../../../controllers/user'
|
||||
import { getTmpFilesFolderPath } from '../../../utils/file'
|
||||
import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils'
|
||||
import path from 'path'
|
||||
import { generateAccessToken } from '../../../controllers/auth'
|
||||
import { saveTokensInDB } from '../../../utils'
|
||||
import { FolderMember, ServiceMember } from '../../../types'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const user = {
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
describe('files', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
const controller = new UserController()
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
describe('deploy', () => {
|
||||
let accessToken: string
|
||||
let dbUser: any
|
||||
|
||||
beforeAll(async () => {
|
||||
dbUser = await controller.createUser(user)
|
||||
accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
})
|
||||
const shouldFailAssertion = async (payload: any) => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(payload)
|
||||
|
||||
expect(res.statusCode).toEqual(400)
|
||||
expect(res.body).toEqual({
|
||||
status: 'failure',
|
||||
message: 'Provided not supported data format.',
|
||||
example: getTreeExample()
|
||||
})
|
||||
}
|
||||
|
||||
it('should respond with payload example if valid payload was not provided', async () => {
|
||||
await shouldFailAssertion(null)
|
||||
await shouldFailAssertion(undefined)
|
||||
await shouldFailAssertion('data')
|
||||
await shouldFailAssertion({})
|
||||
await shouldFailAssertion({
|
||||
userId: 1,
|
||||
title: 'test is cool'
|
||||
})
|
||||
await shouldFailAssertion({
|
||||
membersWRONG: []
|
||||
})
|
||||
await shouldFailAssertion({
|
||||
members: {}
|
||||
})
|
||||
await shouldFailAssertion({
|
||||
members: [
|
||||
{
|
||||
nameWRONG: 'jobs',
|
||||
type: 'folder',
|
||||
members: []
|
||||
}
|
||||
]
|
||||
})
|
||||
await shouldFailAssertion({
|
||||
members: [
|
||||
{
|
||||
name: 'jobs',
|
||||
type: 'WRONG',
|
||||
members: []
|
||||
}
|
||||
]
|
||||
})
|
||||
await shouldFailAssertion({
|
||||
members: [
|
||||
{
|
||||
name: 'jobs',
|
||||
type: 'folder',
|
||||
members: [
|
||||
{
|
||||
name: 'extract',
|
||||
type: 'folder',
|
||||
members: [
|
||||
{
|
||||
name: 'makedata1',
|
||||
type: 'service',
|
||||
codeWRONG: '%put Hello World!;'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with payload example if valid payload was not provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/drive/deploy')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ fileTree: getTreeExample() })
|
||||
|
||||
expect(res.statusCode).toEqual(200)
|
||||
expect(res.text).toEqual(
|
||||
'{"status":"success","message":"Files deployed successfully to @sasjs/server."}'
|
||||
)
|
||||
await expect(folderExists(getTmpFilesFolderPath())).resolves.toEqual(true)
|
||||
|
||||
const testJobFolder = path.join(
|
||||
getTmpFilesFolderPath(),
|
||||
'jobs',
|
||||
'extract'
|
||||
)
|
||||
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
|
||||
|
||||
const exampleService = getExampleService()
|
||||
const testJobFile = path.join(testJobFolder, exampleService.name) + '.sas'
|
||||
|
||||
console.log(`[testJobFile]`, testJobFile)
|
||||
|
||||
await expect(fileExists(testJobFile)).resolves.toEqual(true)
|
||||
|
||||
await expect(readFile(testJobFile)).resolves.toEqual(exampleService.code)
|
||||
|
||||
await deleteFolder(getTmpFilesFolderPath())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const getExampleService = (): ServiceMember =>
|
||||
((getTreeExample().members[0] as FolderMember).members[0] as FolderMember)
|
||||
.members[0] as ServiceMember
|
||||
514
api/src/routes/api/spec/user.spec.ts
Normal file
514
api/src/routes/api/spec/user.spec.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import app from '../../../app'
|
||||
import UserController from '../../../controllers/user'
|
||||
import { generateAccessToken } from '../../../controllers/auth'
|
||||
import { saveTokensInDB } from '../../../utils'
|
||||
|
||||
const clientId = 'someclientID'
|
||||
const adminUser = {
|
||||
displayName: 'Test Admin',
|
||||
username: 'testAdminUsername',
|
||||
password: '12345678',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
const user = {
|
||||
displayName: 'Test User',
|
||||
username: 'testUsername',
|
||||
password: '87654321',
|
||||
isAdmin: false,
|
||||
isActive: true
|
||||
}
|
||||
|
||||
const controller = new UserController()
|
||||
|
||||
describe('user', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeEach(async () => {
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllUsers()
|
||||
})
|
||||
|
||||
it('should respond with new user', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.username).toEqual(user.username)
|
||||
expect(res.body.displayName).toEqual(user.displayName)
|
||||
expect(res.body.isAdmin).toEqual(user.isAdmin)
|
||||
expect(res.body.isActive).toEqual(user.isActive)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not of an admin account', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId: dbUser.id
|
||||
})
|
||||
await saveTokensInDB(dbUser.id, clientId, accessToken, 'refreshToken')
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is already present', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Username already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if username is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...user,
|
||||
username: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"username" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if password is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...user,
|
||||
password: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"password" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if displayName is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...user,
|
||||
displayName: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"displayName" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeEach(async () => {
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllUsers()
|
||||
})
|
||||
|
||||
it('should respond with updated user when admin user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.username).toEqual(user.username)
|
||||
expect(res.body.displayName).toEqual(newDisplayName)
|
||||
expect(res.body.isAdmin).toEqual(user.isAdmin)
|
||||
expect(res.body.isActive).toEqual(user.isActive)
|
||||
})
|
||||
|
||||
it('should respond with updated user when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({
|
||||
displayName: newDisplayName,
|
||||
username: user.username,
|
||||
password: user.password
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.username).toEqual(user.username)
|
||||
expect(res.body.displayName).toEqual(newDisplayName)
|
||||
expect(res.body.isAdmin).toEqual(user.isAdmin)
|
||||
expect(res.body.isActive).toEqual(user.isActive)
|
||||
})
|
||||
|
||||
it('should respond with Bad Request, only admin can update isAdmin/isActive', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
const newDisplayName = 'My new display Name'
|
||||
|
||||
await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ ...user, displayName: newDisplayName })
|
||||
.expect(400)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.patch('/SASjsApi/user/1234')
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
|
||||
const dbUser1 = await controller.createUser(user)
|
||||
const dbUser2 = await controller.createUser({
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if username is already present', async () => {
|
||||
const dbUser1 = await controller.createUser(user)
|
||||
const dbUser2 = await controller.createUser({
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.patch(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({ username: dbUser2.username })
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Username already exists.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeEach(async () => {
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllUsers()
|
||||
})
|
||||
|
||||
it('should respond with OK when admin user requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with OK when user himself requests', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: user.password })
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request when user himself requests and password is missing', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"password" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized when access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/user/1234')
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized when access token is not of an admin account or himself', async () => {
|
||||
const dbUser1 = await controller.createUser(user)
|
||||
const dbUser2 = await controller.createUser({
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
const accessToken = await generateAndSaveToken(dbUser2.id)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser1.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden when user himself requests and password is incorrect', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const accessToken = await generateAndSaveToken(dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/user/${dbUser.id}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send({ password: 'incorrectpassword' })
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Invalid password.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeEach(async () => {
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllUsers()
|
||||
})
|
||||
|
||||
it('should respond with user', async () => {
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.username).toEqual(user.username)
|
||||
expect(res.body.displayName).toEqual(user.displayName)
|
||||
expect(res.body.isAdmin).toEqual(user.isAdmin)
|
||||
expect(res.body.isActive).toEqual(user.isActive)
|
||||
})
|
||||
|
||||
it('should respond with user when access token is not of an admin account', async () => {
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
|
||||
const dbUser = await controller.createUser(user)
|
||||
const userId = dbUser.id
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/user/${userId}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.username).toEqual(user.username)
|
||||
expect(res.body.displayName).toEqual(user.displayName)
|
||||
expect(res.body.isAdmin).toEqual(user.isAdmin)
|
||||
expect(res.body.isActive).toEqual(user.isActive)
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/1234')
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user/1234')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: User is not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAll', () => {
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeEach(async () => {
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await deleteAllUsers()
|
||||
})
|
||||
|
||||
it('should respond with all users', async () => {
|
||||
await controller.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with all users when access token is not of an admin account', async () => {
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'randomUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: adminUser.username,
|
||||
displayName: adminUser.displayName
|
||||
},
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: 'randomUser',
|
||||
displayName: user.displayName
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/user')
|
||||
.send(user)
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser?: any
|
||||
): Promise<string> => {
|
||||
const dbUser = await controller.createUser(someUser ?? adminUser)
|
||||
|
||||
return generateAndSaveToken(dbUser.id)
|
||||
}
|
||||
|
||||
const generateAndSaveToken = async (userId: number) => {
|
||||
const adminAccessToken = generateAccessToken({
|
||||
clientId,
|
||||
userId
|
||||
})
|
||||
await saveTokensInDB(userId, clientId, adminAccessToken, 'refreshToken')
|
||||
return adminAccessToken
|
||||
}
|
||||
|
||||
const deleteAllUsers = async () => {
|
||||
const { collections } = mongoose.connection
|
||||
const collection = collections['users']
|
||||
await collection.deleteMany({})
|
||||
}
|
||||
93
api/src/routes/api/stp.ts
Normal file
93
api/src/routes/api/stp.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import express from 'express'
|
||||
import { isExecutionQuery } from '../../types'
|
||||
import path from 'path'
|
||||
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../../utils'
|
||||
import { ExecutionController, FileUploadController } from '../../controllers'
|
||||
|
||||
const stpRouter = express.Router()
|
||||
|
||||
const fileUploadController = new FileUploadController()
|
||||
|
||||
stpRouter.get('/execute', async (req, res) => {
|
||||
if (isExecutionQuery(req.query)) {
|
||||
let sasCodePath =
|
||||
path
|
||||
.join(getTmpFilesFolderPath(), req.query._program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
await new ExecutionController()
|
||||
.execute(sasCodePath, undefined, undefined, { ...req.query })
|
||||
.then((result: {}) => {
|
||||
res.status(200).send(result)
|
||||
})
|
||||
.catch((err: {} | string) => {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
...(typeof err === 'object' ? err : { details: err })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: `Please provide the location of SAS code`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
stpRouter.post(
|
||||
'/execute',
|
||||
fileUploadController.preuploadMiddleware,
|
||||
fileUploadController.getMulterUploadObject().any(),
|
||||
async (req: any, res: any) => {
|
||||
let _program
|
||||
if (isExecutionQuery(req.query)) {
|
||||
_program = req.query._program
|
||||
} else if (isExecutionQuery(req.body)) {
|
||||
_program = req.body._program
|
||||
}
|
||||
|
||||
if (_program) {
|
||||
let sasCodePath =
|
||||
path
|
||||
.join(getTmpFilesFolderPath(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
let filesNamesMap = null
|
||||
|
||||
if (req.files && req.files.length > 0) {
|
||||
filesNamesMap = makeFilesNamesMap(req.files)
|
||||
}
|
||||
|
||||
await new ExecutionController()
|
||||
.execute(
|
||||
sasCodePath,
|
||||
undefined,
|
||||
req.sasSession,
|
||||
{ ...req.query, ...req.body },
|
||||
{ filesNamesMap: filesNamesMap },
|
||||
true
|
||||
)
|
||||
.then((result: {}) => {
|
||||
res.status(200).send({
|
||||
status: 'success',
|
||||
...result
|
||||
})
|
||||
})
|
||||
.catch((err: {} | string) => {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
...(typeof err === 'object' ? err : { details: err })
|
||||
})
|
||||
})
|
||||
} else {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: `Please provide the location of SAS code`
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default stpRouter
|
||||
95
api/src/routes/api/user.ts
Normal file
95
api/src/routes/api/user.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import express from 'express'
|
||||
import UserController from '../../controllers/user'
|
||||
import {
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
verifyAdminIfNeeded
|
||||
} from '../../middlewares'
|
||||
import {
|
||||
deleteUserValidation,
|
||||
registerUserValidation,
|
||||
updateUserValidation
|
||||
} from '../../utils'
|
||||
|
||||
const userRouter = express.Router()
|
||||
|
||||
userRouter.post('/', authenticateAccessToken, verifyAdmin, async (req, res) => {
|
||||
const { error, value: body } = registerUserValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.createUser(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
userRouter.get('/', authenticateAccessToken, async (req, res) => {
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.getAllUsers()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
userRouter.get('/:userId', authenticateAccessToken, async (req: any, res) => {
|
||||
const { userId } = req.params
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.getUser(userId)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
userRouter.patch(
|
||||
'/:userId',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req: any, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
// only an admin can update `isActive` and `isAdmin` fields
|
||||
const { error, value: body } = updateUserValidation(req.body, user.isAdmin)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
const response = await controller.updateUser(userId, body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
userRouter.delete(
|
||||
'/:userId',
|
||||
authenticateAccessToken,
|
||||
verifyAdminIfNeeded,
|
||||
async (req: any, res) => {
|
||||
const { user } = req
|
||||
const { userId } = req.params
|
||||
|
||||
// only an admin can delete user without providing password
|
||||
const { error, value: data } = deleteUserValidation(req.body, user.isAdmin)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new UserController()
|
||||
try {
|
||||
await controller.deleteUser(userId, data, user.isAdmin)
|
||||
res.status(200).send('Account Deleted!')
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default userRouter
|
||||
8
api/src/routes/web/index.ts
Normal file
8
api/src/routes/web/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import express from 'express'
|
||||
import webRouter from './web'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use('/', webRouter)
|
||||
|
||||
export default router
|
||||
13
api/src/routes/web/web.ts
Normal file
13
api/src/routes/web/web.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import express from 'express'
|
||||
import { isExecutionQuery } from '../../types'
|
||||
import path from 'path'
|
||||
import { getTmpFilesFolderPath, getWebBuildFolderPath } from '../../utils'
|
||||
import { ExecutionController } from '../../controllers'
|
||||
|
||||
const webRouter = express.Router()
|
||||
|
||||
webRouter.get('/', async (_, res) => {
|
||||
res.sendFile(path.join(getWebBuildFolderPath(), 'index.html'))
|
||||
})
|
||||
|
||||
export default webRouter
|
||||
8
api/src/server.ts
Normal file
8
api/src/server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import app from './app'
|
||||
import { configuration } from '../package.json'
|
||||
|
||||
app.listen(configuration.sasJsPort, () => {
|
||||
console.log(
|
||||
`⚡️[server]: Server is running at http://localhost:${configuration.sasJsPort}`
|
||||
)
|
||||
})
|
||||
5
api/src/types/Execution.ts
Normal file
5
api/src/types/Execution.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ExecutionResult {
|
||||
webout?: string
|
||||
log?: string
|
||||
logPath?: string
|
||||
}
|
||||
47
api/src/types/FileTree.ts
Normal file
47
api/src/types/FileTree.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface FileTree {
|
||||
members: (FolderMember | ServiceMember)[]
|
||||
}
|
||||
|
||||
export enum MemberType {
|
||||
folder = 'folder',
|
||||
service = 'service'
|
||||
}
|
||||
|
||||
export interface FolderMember {
|
||||
name: string
|
||||
type: MemberType.folder
|
||||
members: (FolderMember | ServiceMember)[]
|
||||
}
|
||||
|
||||
export interface ServiceMember {
|
||||
name: string
|
||||
type: MemberType.service
|
||||
code: string
|
||||
}
|
||||
|
||||
export const isFileTree = (arg: any): arg is FileTree =>
|
||||
arg &&
|
||||
arg.members &&
|
||||
Array.isArray(arg.members) &&
|
||||
arg.members.filter(
|
||||
(member: FolderMember | ServiceMember) =>
|
||||
!isFolderMember(member) && !isServiceMember(member)
|
||||
).length === 0
|
||||
|
||||
const isFolderMember = (arg: any): arg is FolderMember =>
|
||||
arg &&
|
||||
typeof arg.name === 'string' &&
|
||||
arg.type === MemberType.folder &&
|
||||
arg.members &&
|
||||
Array.isArray(arg.members) &&
|
||||
arg.members.filter(
|
||||
(member: FolderMember | ServiceMember) =>
|
||||
!isFolderMember(member) && !isServiceMember(member)
|
||||
).length === 0
|
||||
|
||||
const isServiceMember = (arg: any): arg is ServiceMember =>
|
||||
arg &&
|
||||
typeof arg.name === 'string' &&
|
||||
arg.type === MemberType.service &&
|
||||
arg.code &&
|
||||
typeof arg.code === 'string'
|
||||
4
api/src/types/InfoJWT.ts
Normal file
4
api/src/types/InfoJWT.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface InfoJWT {
|
||||
clientId: string
|
||||
userId: number
|
||||
}
|
||||
5
api/src/types/Process.d.ts
vendored
Normal file
5
api/src/types/Process.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare namespace NodeJS {
|
||||
export interface Process {
|
||||
sessionController?: import('../controllers/Session').SessionController
|
||||
}
|
||||
}
|
||||
17
api/src/types/Request.ts
Normal file
17
api/src/types/Request.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MacroVars } from '@sasjs/utils'
|
||||
|
||||
export interface ExecutionQuery {
|
||||
_program: string
|
||||
macroVars?: MacroVars
|
||||
_debug?: number
|
||||
}
|
||||
|
||||
export interface FileQuery {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export const isExecutionQuery = (arg: any): arg is ExecutionQuery =>
|
||||
arg && !Array.isArray(arg) && typeof arg._program === 'string'
|
||||
|
||||
export const isFileQuery = (arg: any): arg is FileQuery =>
|
||||
arg && !Array.isArray(arg) && typeof arg.filePath === 'string'
|
||||
8
api/src/types/Session.ts
Normal file
8
api/src/types/Session.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Session {
|
||||
id: string
|
||||
ready: boolean
|
||||
creationTimeStamp: string
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
}
|
||||
6
api/src/types/TreeNode.ts
Normal file
6
api/src/types/TreeNode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
children: Array<TreeNode>
|
||||
}
|
||||
10
api/src/types/Upload.ts
Normal file
10
api/src/types/Upload.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface MulterFile {
|
||||
fieldname: string
|
||||
originalname: string
|
||||
encoding: string
|
||||
mimetype: string
|
||||
destination: string
|
||||
filename: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
7
api/src/types/index.ts
Normal file
7
api/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// TODO: uppercase types
|
||||
export * from './Execution'
|
||||
export * from './Request'
|
||||
export * from './FileTree'
|
||||
export * from './Session'
|
||||
export * from './InfoJWT'
|
||||
export * from './TreeNode'
|
||||
29
api/src/utils/file.ts
Normal file
29
api/src/utils/file.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import path from 'path'
|
||||
import { getRealPath } from '@sasjs/utils'
|
||||
|
||||
export const getWebBuildFolderPath = () =>
|
||||
getRealPath(path.join(__dirname, '..', '..', '..', 'web', 'build'))
|
||||
|
||||
export const getTmpFolderPath = () =>
|
||||
getRealPath(path.join(__dirname, '..', '..', 'tmp'))
|
||||
|
||||
export const getTmpFilesFolderPath = () =>
|
||||
path.join(getTmpFolderPath(), 'files')
|
||||
|
||||
export const getTmpLogFolderPath = () => path.join(getTmpFolderPath(), 'logs')
|
||||
|
||||
export const getTmpWeboutFolderPath = () =>
|
||||
path.join(getTmpFolderPath(), 'webouts')
|
||||
|
||||
export const getTmpSessionsFolderPath = () =>
|
||||
path.join(getTmpFolderPath(), 'sessions')
|
||||
|
||||
export const generateUniqueFileName = (fileName: string, extension = '') =>
|
||||
[
|
||||
fileName,
|
||||
'-',
|
||||
Math.round(Math.random() * 100000),
|
||||
'-',
|
||||
new Date().getTime(),
|
||||
extension
|
||||
].join('')
|
||||
7
api/src/utils/index.ts
Normal file
7
api/src/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './file'
|
||||
export * from './removeTokensInDB'
|
||||
export * from './saveTokensInDB'
|
||||
export * from './sleep'
|
||||
export * from './upload'
|
||||
export * from './validation'
|
||||
export * from './verifyTokenInDB'
|
||||
15
api/src/utils/removeTokensInDB.ts
Normal file
15
api/src/utils/removeTokensInDB.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const removeTokensInDB = async (userId: number, clientId: string) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user) return
|
||||
|
||||
const tokenObjIndex = user.tokens.findIndex(
|
||||
(tokenObj: any) => tokenObj.clientId === clientId
|
||||
)
|
||||
|
||||
if (tokenObjIndex > -1) {
|
||||
user.tokens.splice(tokenObjIndex, 1)
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
26
api/src/utils/saveTokensInDB.ts
Normal file
26
api/src/utils/saveTokensInDB.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const saveTokensInDB = async (
|
||||
userId: number,
|
||||
clientId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
) => {
|
||||
const user = await User.findOne({ id: userId })
|
||||
if (!user) return
|
||||
|
||||
const currentTokenObj = user.tokens.find(
|
||||
(tokenObj: any) => tokenObj.clientId === clientId
|
||||
)
|
||||
if (currentTokenObj) {
|
||||
currentTokenObj.accessToken = accessToken
|
||||
currentTokenObj.refreshToken = refreshToken
|
||||
} else {
|
||||
user.tokens.push({
|
||||
clientId: clientId,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
})
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
3
api/src/utils/sleep.ts
Normal file
3
api/src/utils/sleep.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const sleep = async (delay: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
89
api/src/utils/upload.ts
Normal file
89
api/src/utils/upload.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getTmpSessionsFolderPath } from '.'
|
||||
import { MulterFile } from '../types/Upload'
|
||||
import { listFilesInFolder } from '@sasjs/utils'
|
||||
|
||||
/**
|
||||
* It will create an object that maps hashed file names to the original names
|
||||
* @param files array of files to be mapped
|
||||
* @returns object
|
||||
*/
|
||||
export const makeFilesNamesMap = (files: MulterFile[]) => {
|
||||
if (!files) return null
|
||||
|
||||
const filesNamesMap: { [key: string]: string } = {}
|
||||
|
||||
for (let file of files) {
|
||||
filesNamesMap[file.filename] = file.fieldname
|
||||
}
|
||||
|
||||
return filesNamesMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the sas code that references uploaded files in the concurrent request
|
||||
* @param filesNamesMap object that maps hashed file names and original file names
|
||||
* @param sasUploadFolder name of the folder that is created for the purpose of files in concurrent request
|
||||
* @returns generated sas code
|
||||
*/
|
||||
export const generateFileUploadSasCode = async (
|
||||
filesNamesMap: any,
|
||||
sasSessionFolder: string
|
||||
): Promise<string> => {
|
||||
let uploadSasCode = ''
|
||||
let fileCount = 0
|
||||
let uploadedFilesMap: {
|
||||
fileref: string
|
||||
filepath: string
|
||||
filename: string
|
||||
count: number
|
||||
}[] = []
|
||||
|
||||
const sasSessionFolderList: string[] = await listFilesInFolder(
|
||||
sasSessionFolder
|
||||
)
|
||||
sasSessionFolderList.forEach((fileName) => {
|
||||
let fileCountString = fileCount < 100 ? '0' + fileCount : fileCount
|
||||
fileCountString = fileCount < 10 ? '00' + fileCount : fileCount
|
||||
|
||||
if (fileName.includes('req_file')) {
|
||||
fileCount++
|
||||
|
||||
uploadedFilesMap.push({
|
||||
fileref: `_sjs${fileCountString}`,
|
||||
filepath: `${sasSessionFolder}/${fileName}`,
|
||||
filename: filesNamesMap[fileName],
|
||||
count: fileCount
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\nfilename ${uploadedMap.fileref} "${uploadedMap.filepath}";`
|
||||
}
|
||||
|
||||
uploadSasCode += `\n%let _WEBIN_FILE_COUNT=${fileCount};`
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\n%let _WEBIN_FILENAME${uploadedMap.count}=${uploadedMap.filepath};`
|
||||
}
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\n%let _WEBIN_FILEREF${uploadedMap.count}=${uploadedMap.fileref};`
|
||||
}
|
||||
|
||||
for (let uploadedMap of uploadedFilesMap) {
|
||||
uploadSasCode += `\n%let _WEBIN_NAME${uploadedMap.count}=${uploadedMap.filename};`
|
||||
}
|
||||
|
||||
if (fileCount > 0) {
|
||||
uploadSasCode += `\n%let _WEBIN_NAME=&_WEBIN_NAME1;`
|
||||
uploadSasCode += `\n%let _WEBIN_FILEREF=&_WEBIN_FILEREF1;`
|
||||
uploadSasCode += `\n%let _WEBIN_FILENAME=&_WEBIN_FILENAME1;`
|
||||
}
|
||||
|
||||
uploadSasCode += `\n`
|
||||
|
||||
return uploadSasCode
|
||||
}
|
||||
67
api/src/utils/validation.ts
Normal file
67
api/src/utils/validation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import Joi from 'joi'
|
||||
|
||||
const usernameSchema = Joi.string().alphanum().min(6).max(20)
|
||||
const passwordSchema = Joi.string().min(6).max(1024)
|
||||
|
||||
export const authorizeValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
username: usernameSchema.required(),
|
||||
password: passwordSchema.required(),
|
||||
clientId: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const tokenValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
clientId: Joi.string().required(),
|
||||
code: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const registerGroupValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
name: Joi.string().min(6).required(),
|
||||
description: Joi.string(),
|
||||
isActive: Joi.boolean()
|
||||
}).validate(data)
|
||||
|
||||
export const registerUserValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
displayName: Joi.string().min(6).required(),
|
||||
username: usernameSchema.required(),
|
||||
password: passwordSchema.required(),
|
||||
isAdmin: Joi.boolean(),
|
||||
isActive: Joi.boolean()
|
||||
}).validate(data)
|
||||
|
||||
export const deleteUserValidation = (
|
||||
data: any,
|
||||
isAdmin: boolean = false
|
||||
): Joi.ValidationResult =>
|
||||
Joi.object(
|
||||
isAdmin
|
||||
? {}
|
||||
: {
|
||||
password: passwordSchema.required()
|
||||
}
|
||||
).validate(data)
|
||||
|
||||
export const updateUserValidation = (
|
||||
data: any,
|
||||
isAdmin: boolean = false
|
||||
): Joi.ValidationResult => {
|
||||
const validationChecks: any = {
|
||||
displayName: Joi.string().min(6),
|
||||
username: usernameSchema,
|
||||
password: passwordSchema
|
||||
}
|
||||
if (isAdmin) {
|
||||
validationChecks.isAdmin = Joi.boolean()
|
||||
validationChecks.isActive = Joi.boolean()
|
||||
}
|
||||
return Joi.object(validationChecks).validate(data)
|
||||
}
|
||||
|
||||
export const registerClientValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
clientId: Joi.string().required(),
|
||||
clientSecret: Joi.string().required()
|
||||
}).validate(data)
|
||||
26
api/src/utils/verifyTokenInDB.ts
Normal file
26
api/src/utils/verifyTokenInDB.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import User from '../model/User'
|
||||
|
||||
export const verifyTokenInDB = async (
|
||||
userId: number,
|
||||
clientId: string,
|
||||
token: string,
|
||||
tokenType: 'accessToken' | 'refreshToken'
|
||||
) => {
|
||||
const dbUser = await User.findOne({ id: userId })
|
||||
|
||||
if (!dbUser) return undefined
|
||||
|
||||
const currentTokenObj = dbUser.tokens.find(
|
||||
(tokenObj: any) => tokenObj.clientId === clientId
|
||||
)
|
||||
|
||||
return currentTokenObj?.[tokenType] === token
|
||||
? {
|
||||
userId: dbUser.id,
|
||||
clientId,
|
||||
username: dbUser.username,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isActive: dbUser.isActive
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
16
api/tsconfig.json
Normal file
16
api/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"rootDir": "./",
|
||||
"outDir": "./build",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"ts-node": {
|
||||
"files": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user