1
0
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:
Saad Jutt
2021-11-09 18:24:35 +05:00
83 changed files with 43080 additions and 10960 deletions

15
api/jest.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

65
api/package.json Normal file
View 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
View 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

View 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
}

View 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
}
}

View 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
}
}

View 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
View 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)
})
})
}

View 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
}
}

View 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!;'
}
]
}
]
}
]
})

View 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
}
}

View 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
View 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 })
}

View 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)
})
}

View File

@@ -0,0 +1,3 @@
export * from './authenticateToken'
export * from './verifyAdmin'
export * from './verifyAdminIfNeeded'

View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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()
})
})
})

View 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({})
})
})
})

View 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

View 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
View 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

View 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

View 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
View 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
View 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}`
)
})

View File

@@ -0,0 +1,5 @@
export interface ExecutionResult {
webout?: string
log?: string
logPath?: string
}

47
api/src/types/FileTree.ts Normal file
View 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
View File

@@ -0,0 +1,4 @@
export interface InfoJWT {
clientId: string
userId: number
}

5
api/src/types/Process.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace NodeJS {
export interface Process {
sessionController?: import('../controllers/Session').SessionController
}
}

17
api/src/types/Request.ts Normal file
View 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
View File

@@ -0,0 +1,8 @@
export interface Session {
id: string
ready: boolean
creationTimeStamp: string
deathTimeStamp: string
path: string
inUse: boolean
}

View 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
View 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
View 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
View 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
View 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'

View 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()
}
}

View 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
View 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
View 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
}

View 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)

View 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
View 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
}
}