mirror of
https://github.com/sasjs/server.git
synced 2025-12-11 03:34:35 +00:00
Merge pull request #30 from sasjs/authentication-with-jwt
Authentication with jwt
This commit is contained in:
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
**/.classpath
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
!.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
api/build
|
||||
api/coverage
|
||||
api/build
|
||||
api/node_modules
|
||||
api/public
|
||||
api/web
|
||||
web/build
|
||||
web/node_modules
|
||||
README.md
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
SAS_EXEC=<path to folder containing SAS executable 'sas'>
|
||||
PORT_API=<port for sasjs server (api)>
|
||||
PORT_WEB=<port for sasjs web component(react)>
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -1,7 +1,6 @@
|
||||
name: SASjs Server Build
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -10,18 +9,21 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [lts/*]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check Api Code Style
|
||||
run: npm run lint-api
|
||||
|
||||
- name: Check Web Code Style
|
||||
run: npm run lint-web
|
||||
|
||||
@@ -30,20 +32,29 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [lts/*]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: ./api
|
||||
run: npm ci
|
||||
|
||||
- name: Run Unit Tests
|
||||
working-directory: ./api
|
||||
run: npm test
|
||||
env:
|
||||
CI: true
|
||||
MODE: 'server'
|
||||
ACCESS_TOKEN_SECRET: ${{secrets.ACCESS_TOKEN_SECRET}}
|
||||
REFRESH_TOKEN_SECRET: ${{secrets.REFRESH_TOKEN_SECRET}}
|
||||
AUTH_CODE_SECRET: ${{secrets.AUTH_CODE_SECRET}}
|
||||
|
||||
- name: Build Package
|
||||
working-directory: ./api
|
||||
run: npm run build
|
||||
@@ -55,21 +66,24 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [lts/*]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: ./web
|
||||
run: npm ci
|
||||
|
||||
# TODO: Uncomment next step when unit tests provided
|
||||
# - name: Run Unit Tests
|
||||
# working-directory: ./web
|
||||
# run: npm test
|
||||
|
||||
- name: Build Package
|
||||
working-directory: ./web
|
||||
run: npm run build
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ sas/
|
||||
tmp/
|
||||
build/
|
||||
certificates/
|
||||
executables/
|
||||
.env
|
||||
|
||||
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Docker Node.js Launch",
|
||||
"type": "docker",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "docker-run: debug",
|
||||
"platform": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
.vscode/tasks.json
vendored
Normal file
35
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "docker-build",
|
||||
"label": "docker-build",
|
||||
"platform": "node",
|
||||
"dockerBuild": {
|
||||
"dockerfile": "${workspaceFolder}/Dockerfile",
|
||||
"context": "${workspaceFolder}",
|
||||
"pull": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "docker-run",
|
||||
"label": "docker-run: release",
|
||||
"dependsOn": ["docker-build"],
|
||||
"platform": "node"
|
||||
},
|
||||
{
|
||||
"type": "docker-run",
|
||||
"label": "docker-run: debug",
|
||||
"dependsOn": ["docker-build"],
|
||||
"dockerRun": {
|
||||
"env": {
|
||||
"DEBUG": "*",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"node": {
|
||||
"enableDebugging": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10
DockerfileApi
Normal file
10
DockerfileApi
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:lts-alpine
|
||||
RUN npm install -g @sasjs/cli
|
||||
WORKDIR /usr/server/api
|
||||
COPY ["package.json","package-lock.json", "./"]
|
||||
RUN npm ci
|
||||
COPY ./api .
|
||||
COPY ./certificates ../certificates
|
||||
# RUN chown -R node /usr/server/api
|
||||
# USER node
|
||||
CMD ["npm","start"]
|
||||
11
DockerfileProd
Normal file
11
DockerfileProd
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:lts-alpine
|
||||
RUN npm install -g @sasjs/cli
|
||||
WORKDIR /usr/server/
|
||||
COPY . .
|
||||
RUN cd web && npm ci --silent
|
||||
RUN cd web && REACT_APP_CLIENT_ID=clientID1 npm run build
|
||||
RUN cd api && npm ci --silent
|
||||
# RUN chown -R node /usr/server/api
|
||||
# USER node
|
||||
WORKDIR /usr/server/api
|
||||
CMD ["npm","run","start:prod"]
|
||||
106
README.md
106
README.md
@@ -2,9 +2,9 @@
|
||||
|
||||
SASjs Server provides a NodeJS wrapper for calling the SAS binary executable. It can be installed on an actual SAS server, or it could even run locally on your desktop. It provides the following functionality:
|
||||
|
||||
* Virtual filesystem for storing SAS programs and other content
|
||||
* Ability to execute Stored Programs from a URL
|
||||
* Ability to create web apps using simple Desktop SAS
|
||||
- Virtual filesystem for storing SAS programs and other content
|
||||
- Ability to execute Stored Programs from a URL
|
||||
- Ability to create web apps using simple Desktop SAS
|
||||
|
||||
One major benefit of using SASjs Server (alongside other components of the SASjs framework such as the [CLI](https://cli.sasjs.io), [Adapter](https://adapter.sasjs.io) and [Core](https://core.sasjs.io) library) is that the projects you create can be very easily ported to SAS 9 (Stored Process server) or Viya (Job Execution server).
|
||||
|
||||
@@ -13,4 +13,102 @@ One major benefit of using SASjs Server (alongside other components of the SASjs
|
||||
Configuration is made in the `configuration` section of `package.json`:
|
||||
|
||||
- Provide path to SAS9 executable.
|
||||
- Provide `SASjsServer` hostname and port (eg `localhost:5000`).
|
||||
|
||||
### Using dockers:
|
||||
|
||||
There is `.env.example` file present at root of the project. [for Production]
|
||||
There is `.env.example` file present at `./api` of the project. [for Development]
|
||||
There is `.env.example` file present at `./web` of the project. [for Development]
|
||||
Remember to provide enviornment variables.
|
||||
|
||||
#### Development
|
||||
|
||||
Command to run docker for development:
|
||||
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
It uses default docker compose file i.e. `docker-compose.yml` present at root.
|
||||
It will build following images if running first time:
|
||||
|
||||
- `sasjs_server_api` - image for sasjs api server app based on _ExpressJS_
|
||||
- `sasjs_server_web` - image for sasjs web component app based on _ReactJS_
|
||||
- `mongodb` - image for mongo database
|
||||
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
||||
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
||||
|
||||
#### Production
|
||||
|
||||
Command to run docker for production:
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
It uses specified docker compose file i.e. `docker-compose.prod.yml` present at root.
|
||||
It will build following images if running first time:
|
||||
|
||||
- `sasjs_server_prod` - image for sasjs server app containing api and web component's build served at route `/`
|
||||
- `mongodb` - image for mongo database
|
||||
- `mongo-seed-users` - will be populating user data specified in _./mongo-seed/users/user.json_
|
||||
- `mongo-seed-clients` - will be populating client data specified in _./mongo-seed/clients/client.json_
|
||||
|
||||
### Using node:
|
||||
|
||||
#### Development (running api and web seperately):
|
||||
|
||||
##### API
|
||||
|
||||
Navigate to `./api`
|
||||
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
||||
Command to install and run api server.
|
||||
|
||||
```
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
##### Web
|
||||
|
||||
Navigate to `./web`
|
||||
There is `.env.example` file present at `./web` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
||||
Command to install and run api server.
|
||||
|
||||
```
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
#### Development (running only api server and have web build served):
|
||||
|
||||
##### API server also serving Web build files
|
||||
|
||||
There is `.env.example` file present at `./api` directory. Remember to provide enviornment variables else default values will be used mentioned in `.env.example` files
|
||||
Command to install and run api server.
|
||||
|
||||
```
|
||||
cd ./web && npm i && npm build && cd ../
|
||||
cd ./api && npm i && npm start
|
||||
```
|
||||
|
||||
#### Production
|
||||
|
||||
##### API & WEB
|
||||
|
||||
```
|
||||
npm run server
|
||||
```
|
||||
|
||||
This will install/build `web` and install `api`, then start prod server.
|
||||
|
||||
## Executables
|
||||
|
||||
Command to generate executables
|
||||
|
||||
```
|
||||
cd ./web && npm i && npm build && cd ../
|
||||
cd ./api && npm i && npm run exe
|
||||
```
|
||||
|
||||
This will install/build web app and install/create executables of sasjs server at root `./executables`
|
||||
|
||||
6
api/.dockerignore
Normal file
6
api/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
build
|
||||
coverage
|
||||
node_modules
|
||||
public
|
||||
web
|
||||
Dockerfile
|
||||
8
api/.env.example
Normal file
8
api/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
MODE=[desktop|server] default considered as desktop
|
||||
CORS=[disable|enable] default considered as disable
|
||||
PORT=[5000] default value is 5000
|
||||
PORT_WEB=[port for sasjs web component(react)] default value is 3000
|
||||
ACCESS_TOKEN_SECRET=<secret>
|
||||
REFRESH_TOKEN_SECRET=<secret>
|
||||
AUTH_CODE_SECRET=<secret>
|
||||
DB_CONNECT=mongodb+srv://<DB_USERNAME>:<DB_PASSWORD>@<CLUSTER>/<DB_NAME>?retryWrites=true&w=majority
|
||||
16907
api/package-lock.json
generated
16907
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,34 @@
|
||||
"description": "Api of SASjs server",
|
||||
"main": "./src/server.ts",
|
||||
"scripts": {
|
||||
"prestart": "npm run swagger",
|
||||
"prestart:prod": "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"
|
||||
"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",
|
||||
"exe": "npm run build && npm run public:copy && npm run web:copy && pkg .",
|
||||
"public:copy": "cp -r ./public/ ./build/public/",
|
||||
"web:copy": "rimraf web && mkdir web && cp -r ../web/build/ ./web/build/"
|
||||
},
|
||||
"bin": "./build/src/server.js",
|
||||
"pkg": {
|
||||
"assets": [
|
||||
"./build/public/**/*",
|
||||
"./web/build/**/*"
|
||||
],
|
||||
"targets": [
|
||||
"node16-linux-x64",
|
||||
"node16-macos-x64",
|
||||
"node16-win-x64"
|
||||
],
|
||||
"outputPath": "../executables"
|
||||
},
|
||||
"release": {
|
||||
"branches": [
|
||||
@@ -18,18 +40,39 @@
|
||||
},
|
||||
"author": "Analytium Ltd",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "^2.23.3",
|
||||
"@sasjs/utils": "^2.33.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
"multer": "^1.4.3"
|
||||
"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",
|
||||
"shelljs": "^0.8.4",
|
||||
"swagger-ui-express": "^4.1.6",
|
||||
"tsoa": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@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/shelljs": "^0.8.9",
|
||||
"@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",
|
||||
"pkg": "^5.4.1",
|
||||
"prettier": "^2.3.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"semantic-release": "^17.4.3",
|
||||
"supertest": "^6.1.3",
|
||||
@@ -38,7 +81,6 @@
|
||||
"typescript": "^4.3.2"
|
||||
},
|
||||
"configuration": {
|
||||
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4/sas",
|
||||
"sasJsPort": 5005
|
||||
"sasPath": "/opt/sas/sas9/SASHome/SASFoundation/9.4"
|
||||
}
|
||||
}
|
||||
|
||||
8933
api/public/SASjsApi/swagger-ui.css
Normal file
8933
api/public/SASjsApi/swagger-ui.css
Normal file
File diff suppressed because it is too large
Load Diff
1050
api/public/swagger.yaml
Normal file
1050
api/public/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,28 @@
|
||||
import path from 'path'
|
||||
import express from 'express'
|
||||
import morgan from 'morgan'
|
||||
import dotenv from 'dotenv'
|
||||
import cors from 'cors'
|
||||
|
||||
import webRouter from './routes/web'
|
||||
import apiRouter from './routes/api'
|
||||
import { getWebBuildFolderPath } from './utils'
|
||||
import { connectDB, getWebBuildFolderPath } from './utils'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app = express()
|
||||
|
||||
const { MODE, CORS, PORT_WEB } = process.env
|
||||
if (MODE?.trim() !== 'server' || CORS?.trim() === 'enable') {
|
||||
console.log('All CORS Requests are enabled')
|
||||
app.use(
|
||||
cors({ credentials: true, origin: `http://localhost:${PORT_WEB ?? 3000}` })
|
||||
)
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
app.use(morgan('tiny'))
|
||||
app.use(express.static(path.join(__dirname, '../public')))
|
||||
|
||||
app.use('/', webRouter)
|
||||
app.use('/SASjsApi', apiRouter)
|
||||
@@ -13,4 +30,4 @@ app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
app.use(express.static(getWebBuildFolderPath()))
|
||||
|
||||
export default app
|
||||
export default connectDB().then(() => app)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { fileExists, readFile, createFile } from '@sasjs/utils'
|
||||
|
||||
export class DriveController {
|
||||
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.'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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
|
||||
}
|
||||
212
api/src/controllers/auth.ts
Normal file
212
api/src/controllers/auth.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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 {
|
||||
generateAccessToken,
|
||||
generateAuthCode,
|
||||
generateRefreshToken,
|
||||
removeTokensInDB,
|
||||
saveTokensInDB
|
||||
} from '../utils'
|
||||
|
||||
@Route('SASjsApi/auth')
|
||||
@Tags('Auth')
|
||||
export 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]
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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!)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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
|
||||
}
|
||||
|
||||
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 class ClientController {
|
||||
/**
|
||||
* @summary 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
|
||||
}
|
||||
}
|
||||
243
api/src/controllers/drive.ts
Normal file
243
api/src/controllers/drive.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Example,
|
||||
Post,
|
||||
Body,
|
||||
Response,
|
||||
Query,
|
||||
Get,
|
||||
Patch
|
||||
} from 'tsoa'
|
||||
import { fileExists, readFile, createFile } from '@sasjs/utils'
|
||||
import { createFileTree, ExecutionController, getTreeExample } from './internal'
|
||||
|
||||
import { FileTree, isFileTree, TreeNode } from '../types'
|
||||
import path from 'path'
|
||||
import { getTmpFilesFolderPath } from '../utils'
|
||||
|
||||
interface DeployPayload {
|
||||
appLoc?: string
|
||||
fileTree: FileTree
|
||||
}
|
||||
interface FilePayload {
|
||||
/**
|
||||
* Path of the file
|
||||
* @example "/Public/somefolder/some.file"
|
||||
*/
|
||||
filePath: string
|
||||
/**
|
||||
* Contents of the file
|
||||
* @example "Contents of the File"
|
||||
*/
|
||||
fileContent: string
|
||||
}
|
||||
|
||||
interface DeployResponse {
|
||||
status: string
|
||||
message: string
|
||||
example?: FileTree
|
||||
}
|
||||
|
||||
interface GetFileResponse {
|
||||
status: string
|
||||
fileContent?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface GetFileTreeResponse {
|
||||
status: string
|
||||
tree: TreeNode
|
||||
}
|
||||
|
||||
interface UpdateFileResponse {
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const fileTreeExample = getTreeExample()
|
||||
|
||||
const successDeployResponse: DeployResponse = {
|
||||
status: 'success',
|
||||
message: 'Files deployed successfully to @sasjs/server.'
|
||||
}
|
||||
const invalidDeployFormatResponse: DeployResponse = {
|
||||
status: 'failure',
|
||||
message: 'Provided not supported data format.',
|
||||
example: fileTreeExample
|
||||
}
|
||||
const execDeployErrorResponse: DeployResponse = {
|
||||
status: 'failure',
|
||||
message: 'Deployment failed!'
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/drive')
|
||||
@Tags('Drive')
|
||||
export class DriveController {
|
||||
/**
|
||||
* @summary Creates/updates files within SASjs Drive using provided payload.
|
||||
*
|
||||
*/
|
||||
@Example<DeployResponse>(successDeployResponse)
|
||||
@Response<DeployResponse>(400, 'Invalid Format', invalidDeployFormatResponse)
|
||||
@Response<DeployResponse>(500, 'Execution Error', execDeployErrorResponse)
|
||||
@Post('/deploy')
|
||||
public async deploy(@Body() body: DeployPayload): Promise<DeployResponse> {
|
||||
return deploy(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get file from SASjs Drive
|
||||
* @query filePath Location of SAS program
|
||||
* @example filePath "/Public/somefolder/some.file"
|
||||
*/
|
||||
@Example<GetFileResponse>({
|
||||
status: 'success',
|
||||
fileContent: 'Contents of the File'
|
||||
})
|
||||
@Response<GetFileResponse>(400, 'Unable to get File', {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@Get('/file')
|
||||
public async getFile(@Query() filePath: string): Promise<GetFileResponse> {
|
||||
return getFile(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create a file in SASjs Drive
|
||||
*
|
||||
*/
|
||||
@Example<UpdateFileResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<UpdateFileResponse>(400, 'File already exists', {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@Post('/file')
|
||||
public async saveFile(
|
||||
@Body() body: FilePayload
|
||||
): Promise<UpdateFileResponse> {
|
||||
return saveFile(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Modify a file in SASjs Drive
|
||||
*
|
||||
*/
|
||||
@Example<UpdateFileResponse>({
|
||||
status: 'success'
|
||||
})
|
||||
@Response<UpdateFileResponse>(400, 'Unable to get File', {
|
||||
status: 'failure',
|
||||
message: 'File request failed.'
|
||||
})
|
||||
@Patch('/file')
|
||||
public async updateFile(
|
||||
@Body() body: FilePayload
|
||||
): Promise<UpdateFileResponse> {
|
||||
return updateFile(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Fetch file tree within SASjs Drive.
|
||||
*
|
||||
*/
|
||||
@Get('/filetree')
|
||||
public async getFileTree(): Promise<GetFileTreeResponse> {
|
||||
return getFileTree()
|
||||
}
|
||||
}
|
||||
|
||||
const getFileTree = () => {
|
||||
const tree = new ExecutionController().buildDirectorytree()
|
||||
return { status: 'success', tree }
|
||||
}
|
||||
|
||||
const deploy = async (data: DeployPayload) => {
|
||||
if (!isFileTree(data.fileTree)) {
|
||||
throw { code: 400, ...invalidDeployFormatResponse }
|
||||
}
|
||||
|
||||
await createFileTree(
|
||||
data.fileTree.members,
|
||||
data.appLoc ? data.appLoc.replace(/^\//, '').split('/') : []
|
||||
).catch((err) => {
|
||||
throw { code: 500, ...execDeployErrorResponse, ...err }
|
||||
})
|
||||
|
||||
return successDeployResponse
|
||||
}
|
||||
|
||||
const getFile = async (filePath: string): Promise<GetFileResponse> => {
|
||||
try {
|
||||
const filePathFull = path
|
||||
.join(getTmpFilesFolderPath(), filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
await validateFilePath(filePathFull)
|
||||
const fileContent = await readFile(filePathFull)
|
||||
|
||||
return { status: 'success', fileContent: fileContent }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveFile = async (body: FilePayload): Promise<GetFileResponse> => {
|
||||
const { filePath, fileContent } = body
|
||||
try {
|
||||
const filePathFull = path
|
||||
.join(getTmpFilesFolderPath(), filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
if (await fileExists(filePathFull)) {
|
||||
throw 'DriveController: File already exists.'
|
||||
}
|
||||
await createFile(filePathFull, fileContent)
|
||||
|
||||
return { status: 'success' }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateFile = async (body: FilePayload): Promise<GetFileResponse> => {
|
||||
const { filePath, fileContent } = body
|
||||
try {
|
||||
const filePathFull = path
|
||||
.join(getTmpFilesFolderPath(), filePath)
|
||||
.replace(new RegExp('/', 'g'), path.sep)
|
||||
|
||||
await validateFilePath(filePathFull)
|
||||
await createFile(filePathFull, fileContent)
|
||||
|
||||
return { status: 'success' }
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'File request failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateFilePath = async (filePath: string) => {
|
||||
if (!(await fileExists(filePath))) {
|
||||
throw 'DriveController: File does not exists.'
|
||||
}
|
||||
}
|
||||
220
api/src/controllers/group.ts
Normal file
220
api/src/controllers/group.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
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 class GroupController {
|
||||
/**
|
||||
* @summary 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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 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> =>
|
||||
updateUsersListInGroup(groupId, userId, 'addUser')
|
||||
|
||||
const removeUserFromGroup = async (
|
||||
groupId: number,
|
||||
userId: number
|
||||
): Promise<GroupDetailsResponse> =>
|
||||
updateUsersListInGroup(groupId, userId, 'removeUser')
|
||||
|
||||
const updateUsersListInGroup = async (
|
||||
groupId: number,
|
||||
userId: number,
|
||||
action: 'addUser' | 'removeUser'
|
||||
): 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 = (action === 'addUser'
|
||||
? await group.addUser(user._id)
|
||||
: 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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './deploy'
|
||||
export * from './Drive'
|
||||
export * from './Session'
|
||||
export * from './Execution'
|
||||
export * from './FileUploadController'
|
||||
export * from './auth'
|
||||
export * from './client'
|
||||
export * from './drive'
|
||||
export * from './group'
|
||||
export * from './stp'
|
||||
export * from './user'
|
||||
|
||||
143
api/src/controllers/internal/Execution.ts
Normal file
143
api/src/controllers/internal/Execution.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getSessionController } from './'
|
||||
import { readFile, fileExists, createFile, moveFile } from '@sasjs/utils'
|
||||
import { PreProgramVars, TreeNode } from '../../types'
|
||||
import { generateFileUploadSasCode, getTmpFilesFolderPath } from '../../utils'
|
||||
|
||||
export class ExecutionController {
|
||||
async execute(
|
||||
programPath: string,
|
||||
preProgramVariables: PreProgramVars,
|
||||
vars: { [key: string]: string | number | undefined },
|
||||
otherArgs?: any,
|
||||
returnJson?: boolean
|
||||
) {
|
||||
if (!(await fileExists(programPath)))
|
||||
throw 'ExecutionController: SAS file does not exist.'
|
||||
|
||||
let program = await readFile(programPath)
|
||||
|
||||
Object.keys(vars).forEach(
|
||||
(key: string) => (program = `%let ${key}=${vars[key]};\n${program}`)
|
||||
)
|
||||
|
||||
const sessionController = getSessionController()
|
||||
|
||||
const session = await sessionController.getSession()
|
||||
session.inUse = true
|
||||
|
||||
const logPath = path.join(session.path, 'log.log')
|
||||
|
||||
const weboutPath = path.join(session.path, 'webout.txt')
|
||||
await createFile(weboutPath, '')
|
||||
|
||||
const tokenFile = path.join(session.path, 'accessToken.txt')
|
||||
await createFile(
|
||||
tokenFile,
|
||||
preProgramVariables?.accessToken ?? 'accessToken'
|
||||
)
|
||||
|
||||
program = `
|
||||
%let _sasjs_tokenfile=${tokenFile};
|
||||
%let _sasjs_username=${preProgramVariables?.username};
|
||||
%let _sasjs_userid=${preProgramVariables?.userId};
|
||||
%let _sasjs_displayname=${preProgramVariables?.displayName};
|
||||
%let _sasjs_apiserverurl=${preProgramVariables?.serverUrl};
|
||||
%let _sasjs_apipath=/SASjsApi/stp/execute;
|
||||
%let _metaperson=&_sasjs_displayname;
|
||||
%let _metauser=&_sasjs_username;
|
||||
%let sasjsprocessmode=Stored Program;
|
||||
filename _webout "${weboutPath}";
|
||||
${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 codePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// Creating this file in a RUNNING session will break out
|
||||
// the autoexec loop and actually execute the program
|
||||
// but - given it will take several milliseconds to create
|
||||
// (which can mean SAS trying to run a partial program, or
|
||||
// failing due to file lock) we first create the file THEN
|
||||
// we rename it.
|
||||
await createFile(codePath + '.bkp', program)
|
||||
await moveFile(codePath + '.bkp', codePath)
|
||||
|
||||
// we now need to poll the session array
|
||||
while (!session.completed) {
|
||||
await delay(50)
|
||||
}
|
||||
|
||||
const log =
|
||||
((await fileExists(logPath)) ? await readFile(logPath) : '') +
|
||||
session.crashed
|
||||
const webout = (await fileExists(weboutPath))
|
||||
? await readFile(weboutPath)
|
||||
: ''
|
||||
|
||||
const debugValue =
|
||||
typeof vars._debug === 'string' ? parseInt(vars._debug) : vars._debug
|
||||
|
||||
let debugResponse: string | undefined
|
||||
if ((debugValue && debugValue >= 131) || session.crashed) {
|
||||
debugResponse = `<html><body>${webout}<div style="text-align:left"><hr /><h2>SAS Log</h2><pre>${log}</pre></div></body></html>`
|
||||
}
|
||||
|
||||
session.inUse = false
|
||||
sessionController.deleteSession(session)
|
||||
|
||||
if (returnJson) return { result: debugResponse ?? webout, log }
|
||||
return debugResponse ?? 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
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
157
api/src/controllers/internal/Session.ts
Normal file
157
api/src/controllers/internal/Session.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import path from 'path'
|
||||
import { Session } from '../../types'
|
||||
import { promisify } from 'util'
|
||||
import { execFile } from 'child_process'
|
||||
import { getTmpSessionsFolderPath, generateUniqueFileName } from '../../utils'
|
||||
import {
|
||||
deleteFolder,
|
||||
createFile,
|
||||
fileExists,
|
||||
generateTimestamp
|
||||
} from '@sasjs/utils'
|
||||
|
||||
const execFilePromise = promisify(execFile)
|
||||
|
||||
export class SessionController {
|
||||
private sessions: Session[] = []
|
||||
|
||||
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(getTmpSessionsFolderPath(), sessionId)
|
||||
|
||||
const creationTimeStamp = sessionId.split('-').pop() as string
|
||||
const deathTimeStamp = (
|
||||
parseInt(creationTimeStamp) +
|
||||
15 * 60 * 1000 -
|
||||
1000
|
||||
).toString()
|
||||
|
||||
const session: Session = {
|
||||
id: sessionId,
|
||||
ready: false,
|
||||
inUse: false,
|
||||
completed: false,
|
||||
creationTimeStamp,
|
||||
deathTimeStamp,
|
||||
path: sessionFolder
|
||||
}
|
||||
|
||||
// we do not want to leave sessions running forever
|
||||
// we clean them up after a predefined period, if unused
|
||||
this.scheduleSessionDestroy(session)
|
||||
|
||||
// the autoexec file is executed on SAS startup
|
||||
const autoExecPath = path.join(sessionFolder, 'autoexec.sas')
|
||||
await createFile(autoExecPath, autoExecContent)
|
||||
|
||||
// create empty code.sas as SAS will not start without a SYSIN
|
||||
const codePath = path.join(session.path, 'code.sas')
|
||||
await createFile(codePath, '')
|
||||
|
||||
// trigger SAS but don't wait for completion - we need to
|
||||
// update the session array to say that it is currently running
|
||||
// however we also need a promise so that we can update the
|
||||
// session array to say that it has (eventually) finished.
|
||||
|
||||
execFilePromise(process.sasLoc, [
|
||||
'-SYSIN',
|
||||
codePath,
|
||||
'-LOG',
|
||||
path.join(session.path, 'log.log'),
|
||||
'-WORK',
|
||||
session.path,
|
||||
'-AUTOEXEC',
|
||||
autoExecPath,
|
||||
process.platform === 'win32' ? '-nosplash' : ''
|
||||
])
|
||||
.then(() => {
|
||||
session.completed = true
|
||||
console.log('session completed', session)
|
||||
})
|
||||
.catch((err) => {
|
||||
session.completed = true
|
||||
session.crashed = err.toString()
|
||||
console.log('session crashed', session.id)
|
||||
})
|
||||
|
||||
// we have a triggered session - add to array
|
||||
this.sessions.push(session)
|
||||
|
||||
// SAS has been triggered but we can't use it until
|
||||
// the autoexec deletes the code.sas file
|
||||
await this.waitForSession(session)
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
public async waitForSession(session: Session) {
|
||||
const codeFilePath = path.join(session.path, 'code.sas')
|
||||
|
||||
// TODO: don't wait forever
|
||||
while ((await fileExists(codeFilePath)) && !session.crashed) {}
|
||||
console.log('session crashed?', !!session.crashed, session.crashed)
|
||||
|
||||
session.ready = true
|
||||
return Promise.resolve(session)
|
||||
}
|
||||
|
||||
public async deleteSession(session: Session) {
|
||||
// remove the temporary files, to avoid buildup
|
||||
await deleteFolder(session.path)
|
||||
|
||||
// remove the session from the session array
|
||||
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 = (): SessionController => {
|
||||
if (process.sessionController) return process.sessionController
|
||||
|
||||
process.sessionController = new SessionController()
|
||||
|
||||
return process.sessionController
|
||||
}
|
||||
|
||||
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;
|
||||
stop;
|
||||
run;
|
||||
`
|
||||
@@ -1,11 +1,11 @@
|
||||
import { MemberType, FolderMember, ServiceMember } from '../types'
|
||||
import { getTmpFilesFolderPath } from '../utils/file'
|
||||
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],
|
||||
members: (FolderMember | ServiceMember)[],
|
||||
parentFolders: string[] = []
|
||||
) => {
|
||||
const destinationPath = path.join(
|
||||
@@ -16,7 +16,7 @@ export const createFileTree = async (
|
||||
await asyncForEach(members, async (member: FolderMember | ServiceMember) => {
|
||||
let name = member.name
|
||||
|
||||
if (member.type === 'service') name += '.sas'
|
||||
if (member.type === MemberType.service) name += '.sas'
|
||||
|
||||
if (member.type === MemberType.folder) {
|
||||
await createFolder(path.join(destinationPath, name)).catch((err) =>
|
||||
@@ -36,19 +36,19 @@ export const createFileTree = async (
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export const getTreeExample = () => ({
|
||||
export const getTreeExample = (): FileTree => ({
|
||||
members: [
|
||||
{
|
||||
name: 'jobs',
|
||||
type: 'folder',
|
||||
type: MemberType.folder,
|
||||
members: [
|
||||
{
|
||||
name: 'extract',
|
||||
type: 'folder',
|
||||
type: MemberType.folder,
|
||||
members: [
|
||||
{
|
||||
name: 'makedata1',
|
||||
type: 'service',
|
||||
type: MemberType.service,
|
||||
code: '%put Hello World!;'
|
||||
}
|
||||
]
|
||||
4
api/src/controllers/internal/index.ts
Normal file
4
api/src/controllers/internal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './deploy'
|
||||
export * from './Session'
|
||||
export * from './Execution'
|
||||
export * from './FileUploadController'
|
||||
148
api/src/controllers/stp.ts
Normal file
148
api/src/controllers/stp.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import express, { response } from 'express'
|
||||
import path from 'path'
|
||||
import {
|
||||
Request,
|
||||
Security,
|
||||
Route,
|
||||
Tags,
|
||||
Example,
|
||||
Post,
|
||||
Body,
|
||||
Get,
|
||||
Query
|
||||
} from 'tsoa'
|
||||
import { ExecutionController } from './internal'
|
||||
import { PreProgramVars } from '../types'
|
||||
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../utils'
|
||||
import { request } from 'https'
|
||||
|
||||
interface ExecuteReturnJsonPayload {
|
||||
/**
|
||||
* Location of SAS program
|
||||
* @example "/Public/somefolder/some.file"
|
||||
*/
|
||||
_program?: string
|
||||
}
|
||||
interface ExecuteReturnJsonResponse {
|
||||
status: string
|
||||
log?: string
|
||||
result?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
@Security('bearerAuth')
|
||||
@Route('SASjsApi/stp')
|
||||
@Tags('STP')
|
||||
export class STPController {
|
||||
/**
|
||||
* Trigger a SAS program using it's location in the _program parameter.
|
||||
* Enable debugging using the _debug parameter.
|
||||
* Additional URL parameters are turned into SAS macro variables.
|
||||
* Any files provided are placed into the session and
|
||||
* corresponding _WEBIN_XXX variables are created.
|
||||
* @summary Execute Stored Program, return raw content
|
||||
* @query _program Location of SAS program
|
||||
* @example _program "/Public/somefolder/some.file"
|
||||
*/
|
||||
@Get('/execute')
|
||||
public async executeReturnRaw(
|
||||
@Request() request: express.Request,
|
||||
@Query() _program: string
|
||||
): Promise<string> {
|
||||
return executeReturnRaw(request, _program)
|
||||
}
|
||||
/**
|
||||
* Trigger a SAS program using it's location in the _program parameter.
|
||||
* Enable debugging using the _debug parameter.
|
||||
* Additional URL parameters are turned into SAS macro variables.
|
||||
* Any files provided are placed into the session and
|
||||
* corresponding _WEBIN_XXX variables are created.
|
||||
* @summary Execute Stored Program, return JSON
|
||||
* @query _program Location of SAS program
|
||||
* @example _program "/Public/somefolder/some.file"
|
||||
*/
|
||||
@Post('/execute')
|
||||
public async executeReturnJson(
|
||||
@Request() request: express.Request,
|
||||
@Body() body?: ExecuteReturnJsonPayload,
|
||||
@Query() _program?: string
|
||||
): Promise<ExecuteReturnJsonResponse> {
|
||||
const program = _program ?? body?._program
|
||||
return executeReturnJson(request, program!)
|
||||
}
|
||||
}
|
||||
|
||||
const executeReturnRaw = async (
|
||||
req: express.Request,
|
||||
_program: string
|
||||
): Promise<string> => {
|
||||
const query = req.query as { [key: string]: string | number | undefined }
|
||||
const sasCodePath =
|
||||
path
|
||||
.join(getTmpFilesFolderPath(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
try {
|
||||
const result = await new ExecutionController().execute(
|
||||
sasCodePath,
|
||||
getPreProgramVariables(req),
|
||||
query
|
||||
)
|
||||
|
||||
return result as string
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeReturnJson = async (
|
||||
req: any,
|
||||
_program: string
|
||||
): Promise<ExecuteReturnJsonResponse> => {
|
||||
const sasCodePath =
|
||||
path
|
||||
.join(getTmpFilesFolderPath(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
|
||||
const filesNamesMap = req.files?.length ? makeFilesNamesMap(req.files) : null
|
||||
|
||||
try {
|
||||
const jsonResult: any = await new ExecutionController().execute(
|
||||
sasCodePath,
|
||||
getPreProgramVariables(req),
|
||||
{ ...req.query, ...req.body },
|
||||
{ filesNamesMap: filesNamesMap },
|
||||
true
|
||||
)
|
||||
return {
|
||||
status: 'success',
|
||||
result: jsonResult.result,
|
||||
log: jsonResult.log
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw {
|
||||
code: 400,
|
||||
status: 'failure',
|
||||
message: 'Job execution failed.',
|
||||
error: typeof err === 'object' ? err.toString() : err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getPreProgramVariables = (req: any): PreProgramVars => {
|
||||
const host = req.get('host')
|
||||
const protocol = req.protocol + '://'
|
||||
const { user, accessToken } = req
|
||||
return {
|
||||
username: user.username,
|
||||
userId: user.userId,
|
||||
displayName: user.displayName,
|
||||
serverUrl: protocol + host,
|
||||
accessToken
|
||||
}
|
||||
}
|
||||
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 class UserController {
|
||||
/**
|
||||
* @summary 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 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 })
|
||||
}
|
||||
69
api/src/middlewares/authenticateToken.ts
Normal file
69
api/src/middlewares/authenticateToken.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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 { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') {
|
||||
req.user = {
|
||||
userId: '1234',
|
||||
clientId: 'desktopModeClientId',
|
||||
username: 'desktopModeUsername',
|
||||
displayName: 'desktopModeDisplayName',
|
||||
isAdmin: true,
|
||||
isActive: true
|
||||
}
|
||||
req.accessToken = 'desktopModeAccessToken'
|
||||
return next()
|
||||
}
|
||||
|
||||
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
|
||||
if (tokenType === 'accessToken') req.accessToken = token
|
||||
return next()
|
||||
} else return res.sendStatus(401)
|
||||
}
|
||||
return res.sendStatus(401)
|
||||
})
|
||||
}
|
||||
7
api/src/middlewares/desktopRestrict.ts
Normal file
7
api/src/middlewares/desktopRestrict.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const desktopRestrict = (req: any, res: any, next: any) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server')
|
||||
return res.status(403).send('Not Allowed while in Desktop Mode.')
|
||||
|
||||
next()
|
||||
}
|
||||
4
api/src/middlewares/index.ts
Normal file
4
api/src/middlewares/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './authenticateToken'
|
||||
export * from './desktopRestrict'
|
||||
export * from './verifyAdmin'
|
||||
export * from './verifyAdminIfNeeded'
|
||||
8
api/src/middlewares/verifyAdmin.ts
Normal file
8
api/src/middlewares/verifyAdmin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const verifyAdmin = (req: any, res: any, next: any) => {
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') return next()
|
||||
|
||||
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
|
||||
@@ -1,19 +1,21 @@
|
||||
import path from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import * as https from 'https'
|
||||
import { configuration } from '../package.json'
|
||||
import app from './app'
|
||||
import appPromise from './app'
|
||||
|
||||
const keyPath = path.join('certificates', 'privkey.pem')
|
||||
const certPath = path.join('certificates', 'fullchain.pem')
|
||||
const keyPath = path.join('..', 'certificates', 'privkey.pem')
|
||||
const certPath = path.join('..', 'certificates', 'fullchain.pem')
|
||||
|
||||
const key = readFileSync(keyPath)
|
||||
const cert = readFileSync(certPath)
|
||||
|
||||
appPromise.then((app) => {
|
||||
const httpsServer = https.createServer({ key, cert }, app)
|
||||
|
||||
httpsServer.listen(configuration.sasJsPort, () => {
|
||||
const sasJsPort = process.env.PORT ?? 5000
|
||||
httpsServer.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
`⚡️[server]: Server is running at https://localhost:${configuration.sasJsPort}`
|
||||
`⚡️[server]: Server is running at https://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
89
api/src/routes/api/auth.ts
Normal file
89
api/src/routes/api/auth.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import express from 'express'
|
||||
|
||||
import { AuthController } from '../../controllers/'
|
||||
import Client from '../../model/Client'
|
||||
|
||||
import {
|
||||
authenticateAccessToken,
|
||||
authenticateRefreshToken
|
||||
} from '../../middlewares'
|
||||
|
||||
import {
|
||||
authorizeValidation,
|
||||
getDesktopFields,
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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'
|
||||
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
|
||||
@@ -1,90 +1,82 @@
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import {
|
||||
createFileTree,
|
||||
getTreeExample,
|
||||
DriveController,
|
||||
ExecutionController
|
||||
} from '../../controllers'
|
||||
import { isFileTree, isFileQuery } from '../../types'
|
||||
import { getTmpFilesFolderPath } from '../../utils'
|
||||
import { DriveController } from '../../controllers/'
|
||||
import { getFileDriveValidation, updateFileDriveValidation } from '../../utils'
|
||||
|
||||
const driveRouter = express.Router()
|
||||
|
||||
driveRouter.post('/deploy', async (req, res) => {
|
||||
if (!isFileTree(req.body.fileTree)) {
|
||||
res.status(400).send({
|
||||
status: 'failure',
|
||||
message: 'Provided not supported data format.',
|
||||
example: getTreeExample()
|
||||
})
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.deploy(req.body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
return
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
|
||||
await createFileTree(
|
||||
req.body.fileTree.members,
|
||||
req.body.appLoc ? req.body.appLoc.replace(/^\//, '').split('/') : []
|
||||
)
|
||||
.then(() => {
|
||||
res.status(200).send({
|
||||
status: 'success',
|
||||
message: 'Files deployed successfully to @sasjs/server.'
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
res
|
||||
.status(500)
|
||||
.send({ status: 'failure', message: 'Deployment failed!', ...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'
|
||||
const { error, value: query } = getFileDriveValidation(req.query)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.getFile(query.filePath)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.post('/file', async (req, res) => {
|
||||
const { error, value: body } = updateFileDriveValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.saveFile(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
const { error, value: body } = updateFileDriveValidation(req.body)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.updateFile(body)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
driveRouter.get('/fileTree', async (req, res) => {
|
||||
const tree = new ExecutionController().buildDirectorytree()
|
||||
res.status(200).send({ status: 'success', tree })
|
||||
const controller = new DriveController()
|
||||
try {
|
||||
const response = await controller.getFileTree()
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
res.status(403).send(err.toString())
|
||||
}
|
||||
})
|
||||
|
||||
export default driveRouter
|
||||
|
||||
99
api/src/routes/api/group.ts
Normal file
99
api/src/routes/api/group.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import express from 'express'
|
||||
import { GroupController } from '../../controllers/'
|
||||
import { authenticateAccessToken, verifyAdmin } from '../../middlewares'
|
||||
import { registerGroupValidation } from '../../utils'
|
||||
|
||||
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,
|
||||
verifyAdmin,
|
||||
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,
|
||||
verifyAdmin,
|
||||
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,
|
||||
verifyAdmin,
|
||||
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
|
||||
@@ -1,10 +1,42 @@
|
||||
import express from 'express'
|
||||
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
|
||||
import {
|
||||
authenticateAccessToken,
|
||||
desktopRestrict,
|
||||
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 from './auth'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use('/drive', driveRouter)
|
||||
router.use('/stp', stpRouter)
|
||||
router.use('/auth', desktopRestrict, authRouter)
|
||||
router.use(
|
||||
'/client',
|
||||
desktopRestrict,
|
||||
authenticateAccessToken,
|
||||
verifyAdmin,
|
||||
clientRouter
|
||||
)
|
||||
router.use('/drive', authenticateAccessToken, driveRouter)
|
||||
router.use('/group', desktopRestrict, groupRouter)
|
||||
router.use('/stp', authenticateAccessToken, stpRouter)
|
||||
router.use('/user', desktopRestrict, userRouter)
|
||||
router.use(
|
||||
'/',
|
||||
swaggerUi.serve,
|
||||
swaggerUi.setup(undefined, {
|
||||
swaggerOptions: {
|
||||
url: '/swagger.yaml'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
359
api/src/routes/api/spec/auth.spec.ts
Normal file
359
api/src/routes/api/spec/auth.spec.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import {
|
||||
UserController,
|
||||
ClientController,
|
||||
AuthController
|
||||
} from '../../../controllers/'
|
||||
import { populateClients } from '../auth'
|
||||
import { InfoJWT } from '../../../types'
|
||||
import {
|
||||
generateAccessToken,
|
||||
generateAuthCode,
|
||||
generateRefreshToken,
|
||||
saveTokensInDB,
|
||||
verifyTokenInDB
|
||||
} from '../../../utils'
|
||||
|
||||
let app: Express
|
||||
appPromise.then((_app) => {
|
||||
app = _app
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
162
api/src/routes/api/spec/client.spec.ts
Normal file
162
api/src/routes/api/spec/client.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController, ClientController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
|
||||
let app: Express
|
||||
appPromise.then((_app) => {
|
||||
app = _app
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
beforeAll(async () => {
|
||||
const 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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,61 @@
|
||||
import { Express } from 'express'
|
||||
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 appPromise from '../../../app'
|
||||
import { UserController } from '../../../controllers/'
|
||||
import { getTreeExample } from '../../../controllers/internal'
|
||||
import { getTmpFilesFolderPath } from '../../../utils/file'
|
||||
import { folderExists, fileExists, readFile, deleteFolder } from '@sasjs/utils'
|
||||
import path from 'path'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
import { FolderMember, ServiceMember } from '../../../types'
|
||||
|
||||
let app: Express
|
||||
appPromise.then((_app) => {
|
||||
app = _app
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -79,6 +125,7 @@ describe('files', () => {
|
||||
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)
|
||||
@@ -94,21 +141,20 @@ describe('files', () => {
|
||||
)
|
||||
await expect(folderExists(testJobFolder)).resolves.toEqual(true)
|
||||
|
||||
const testJobFile =
|
||||
path.join(
|
||||
testJobFolder,
|
||||
getTreeExample().members[0].members[0].members[0].name
|
||||
) + '.sas'
|
||||
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(
|
||||
getTreeExample().members[0].members[0].members[0].code
|
||||
)
|
||||
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
|
||||
|
||||
489
api/src/routes/api/spec/group.spec.ts
Normal file
489
api/src/routes/api/spec/group.spec.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController, GroupController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
|
||||
let app: Express
|
||||
appPromise.then((_app) => {
|
||||
app = _app
|
||||
})
|
||||
|
||||
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 group = {
|
||||
name: 'DCGroup1',
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
|
||||
const userController = new UserController()
|
||||
const groupController = new GroupController()
|
||||
|
||||
describe('group', () => {
|
||||
let con: Mongoose
|
||||
let mongoServer: MongoMemoryServer
|
||||
let adminAccessToken: string
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create()
|
||||
con = await mongoose.connect(mongoServer.getUri())
|
||||
|
||||
adminAccessToken = await generateSaveTokenAndCreateUser()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await con.connection.dropDatabase()
|
||||
await con.connection.close()
|
||||
await mongoServer.stop()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with new group', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send(group)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app).post('/SASjsApi/group').send().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 accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'create' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Bad Request if name is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send({
|
||||
...group,
|
||||
name: undefined
|
||||
})
|
||||
.expect(400)
|
||||
|
||||
expect(res.text).toEqual(`"name" is required`)
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with OK when admin user requests', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/1234`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: No Group deleted!')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized when access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/1234')
|
||||
.send()
|
||||
.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', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'delete' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('get', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with group', async () => {
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([])
|
||||
})
|
||||
|
||||
it('should respond with group when access token is not of an admin account', async () => {
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'get' + user.username
|
||||
})
|
||||
|
||||
const { groupId } = await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/SASjsApi/group/${groupId}`)
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group/1234')
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group/1234')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Group not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAll', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with all groups', async () => {
|
||||
await groupController.createGroup(group)
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
name: 'DCGroup1',
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with all groups when access token is not of an admin account', async () => {
|
||||
await groupController.createGroup(group)
|
||||
const accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'getAllrandomUser'
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.get('/SASjsApi/group')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send(user)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
groupId: expect.anything(),
|
||||
name: 'DCGroup1',
|
||||
description: 'DC group for testing purposes.'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app).get('/SASjsApi/group').send().expect(401)
|
||||
|
||||
expect(res.text).toEqual('Unauthorized')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AddUser', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with group having new user in it', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const dbUser = await userController.createUser(user)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: user.username,
|
||||
displayName: user.displayName
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with group without duplicating user', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'addUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([
|
||||
{
|
||||
id: expect.anything(),
|
||||
username: 'addUserRandomUser',
|
||||
displayName: user.displayName
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group/123/123')
|
||||
.send()
|
||||
.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 accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'addUser' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group/123/123')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.post('/SASjsApi/group/123/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Group not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const res = await request(app)
|
||||
.post(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: User not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoveUser', () => {
|
||||
afterEach(async () => {
|
||||
await deleteAllGroups()
|
||||
})
|
||||
|
||||
it('should respond with group having user removed from it', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const dbUser = await userController.createUser({
|
||||
...user,
|
||||
username: 'removeUserRandomUser'
|
||||
})
|
||||
await groupController.addUserToGroup(dbGroup.groupId, dbUser.id)
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/${dbUser.id}`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(200)
|
||||
|
||||
expect(res.body.groupId).toBeTruthy()
|
||||
expect(res.body.name).toEqual(group.name)
|
||||
expect(res.body.description).toEqual(group.description)
|
||||
expect(res.body.isActive).toEqual(true)
|
||||
expect(res.body.users).toEqual([])
|
||||
})
|
||||
|
||||
it('should respond with Unauthorized if access token is not present', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
.send()
|
||||
.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 accessToken = await generateSaveTokenAndCreateUser({
|
||||
...user,
|
||||
username: 'removeUser' + user.username
|
||||
})
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
.auth(accessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(401)
|
||||
|
||||
expect(res.text).toEqual('Admin account required')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if groupId is incorrect', async () => {
|
||||
const res = await request(app)
|
||||
.delete('/SASjsApi/group/123/123')
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: Group not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
|
||||
it('should respond with Forbidden if userId is incorrect', async () => {
|
||||
const dbGroup = await groupController.createGroup(group)
|
||||
const res = await request(app)
|
||||
.delete(`/SASjsApi/group/${dbGroup.groupId}/123`)
|
||||
.auth(adminAccessToken, { type: 'bearer' })
|
||||
.send()
|
||||
.expect(403)
|
||||
|
||||
expect(res.text).toEqual('Error: User not found.')
|
||||
expect(res.body).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const generateSaveTokenAndCreateUser = async (
|
||||
someUser?: any
|
||||
): Promise<string> => {
|
||||
const dbUser = await userController.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 deleteAllGroups = async () => {
|
||||
const { collections } = mongoose.connection
|
||||
const collection = collections['groups']
|
||||
await collection.deleteMany({})
|
||||
}
|
||||
516
api/src/routes/api/spec/user.spec.ts
Normal file
516
api/src/routes/api/spec/user.spec.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { Express } from 'express'
|
||||
import mongoose, { Mongoose } from 'mongoose'
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server'
|
||||
import request from 'supertest'
|
||||
import appPromise from '../../../app'
|
||||
import { UserController } from '../../../controllers/'
|
||||
import { generateAccessToken, saveTokensInDB } from '../../../utils'
|
||||
|
||||
let app: Express
|
||||
appPromise.then((_app) => {
|
||||
app = _app
|
||||
})
|
||||
|
||||
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()
|
||||
.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()
|
||||
.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()
|
||||
.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()
|
||||
.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()
|
||||
.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()
|
||||
.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().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({})
|
||||
}
|
||||
@@ -1,37 +1,26 @@
|
||||
import express from 'express'
|
||||
import { isExecutionQuery } from '../../types'
|
||||
import path from 'path'
|
||||
import { getTmpFilesFolderPath, makeFilesNamesMap } from '../../utils'
|
||||
import { ExecutionController, FileUploadController } from '../../controllers'
|
||||
import { executeProgramRawValidation } from '../../utils'
|
||||
import { STPController } from '../../controllers/'
|
||||
import { FileUploadController } from '../../controllers/internal'
|
||||
|
||||
const stpRouter = express.Router()
|
||||
|
||||
const fileUploadController = new FileUploadController()
|
||||
const controller = new STPController()
|
||||
|
||||
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'
|
||||
const { error, value: query } = executeProgramRawValidation(req.query)
|
||||
if (error) return res.status(400).send(error.details[0].message)
|
||||
|
||||
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`
|
||||
})
|
||||
try {
|
||||
const response = await controller.executeReturnRaw(req, query._program)
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,52 +29,24 @@ stpRouter.post(
|
||||
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
|
||||
}
|
||||
const { error: errQ, value: query } = executeProgramRawValidation(req.query)
|
||||
const { error: errB, value: body } = executeProgramRawValidation(req.body)
|
||||
|
||||
if (_program) {
|
||||
let sasCodePath =
|
||||
path
|
||||
.join(getTmpFilesFolderPath(), _program)
|
||||
.replace(new RegExp('/', 'g'), path.sep) + '.sas'
|
||||
if (errQ && errB) return res.status(400).send(errB.details[0].message)
|
||||
|
||||
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
|
||||
try {
|
||||
const response = await controller.executeReturnJson(
|
||||
req,
|
||||
body,
|
||||
query?._program
|
||||
)
|
||||
.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`
|
||||
})
|
||||
res.send(response)
|
||||
} catch (err: any) {
|
||||
const statusCode = err.code
|
||||
|
||||
delete err.code
|
||||
|
||||
res.status(statusCode).send(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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/'
|
||||
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
|
||||
@@ -1,13 +1,33 @@
|
||||
import { fileExists, readFile } from '@sasjs/utils'
|
||||
import express from 'express'
|
||||
import { isExecutionQuery } from '../../types'
|
||||
import path from 'path'
|
||||
import { getTmpFilesFolderPath, getWebBuildFolderPath } from '../../utils'
|
||||
import { ExecutionController } from '../../controllers'
|
||||
import { getWebBuildFolderPath } from '../../utils'
|
||||
|
||||
const webRouter = express.Router()
|
||||
|
||||
const codeToInject = `
|
||||
<script>
|
||||
localStorage.setItem('accessToken', JSON.stringify('accessToken'))
|
||||
localStorage.setItem('refreshToken', JSON.stringify('refreshToken'))
|
||||
</script>`
|
||||
|
||||
webRouter.get('/', async (_, res) => {
|
||||
res.sendFile(path.join(getWebBuildFolderPath(), 'index.html'))
|
||||
const indexHtmlPath = path.join(getWebBuildFolderPath(), 'index.html')
|
||||
if (!(await fileExists(indexHtmlPath))) {
|
||||
return res.send('Web Build is not present')
|
||||
}
|
||||
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') {
|
||||
const content = await readFile(indexHtmlPath)
|
||||
|
||||
const injectedContent = content.replace('</head>', `${codeToInject}</head>`)
|
||||
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
return res.send(injectedContent)
|
||||
}
|
||||
|
||||
res.sendFile(indexHtmlPath)
|
||||
})
|
||||
|
||||
export default webRouter
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import app from './app'
|
||||
import { configuration } from '../package.json'
|
||||
import appPromise from './app'
|
||||
|
||||
app.listen(configuration.sasJsPort, () => {
|
||||
appPromise.then((app) => {
|
||||
const sasJsPort = process.env.PORT ?? 5000
|
||||
app.listen(sasJsPort, () => {
|
||||
console.log(
|
||||
`⚡️[server]: Server is running at http://localhost:${configuration.sasJsPort}`
|
||||
`⚡️[server]: Server is running at http://localhost:${sasJsPort}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface FileTree {
|
||||
members: [FolderMember, ServiceMember]
|
||||
members: (FolderMember | ServiceMember)[]
|
||||
}
|
||||
|
||||
export enum MemberType {
|
||||
@@ -10,7 +10,7 @@ export enum MemberType {
|
||||
export interface FolderMember {
|
||||
name: string
|
||||
type: MemberType.folder
|
||||
members: [FolderMember, ServiceMember]
|
||||
members: (FolderMember | ServiceMember)[]
|
||||
}
|
||||
|
||||
export interface ServiceMember {
|
||||
|
||||
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
|
||||
}
|
||||
7
api/src/types/PreProgramVars.ts
Normal file
7
api/src/types/PreProgramVars.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface PreProgramVars {
|
||||
username: string
|
||||
userId: number
|
||||
displayName: string
|
||||
serverUrl: string
|
||||
accessToken: string
|
||||
}
|
||||
4
api/src/types/Process.d.ts
vendored
4
api/src/types/Process.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
export interface Process {
|
||||
sessionController?: import('../controllers/Session').SessionController
|
||||
sasLoc: string
|
||||
driveLoc?: string
|
||||
sessionController?: import('../controllers/internal').SessionController
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,6 @@ export interface Session {
|
||||
deathTimeStamp: string
|
||||
path: string
|
||||
inUse: boolean
|
||||
completed: boolean
|
||||
crashed?: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// TODO: uppercase types
|
||||
export * from './Execution'
|
||||
export * from './Request'
|
||||
export * from './FileTree'
|
||||
export * from './InfoJWT'
|
||||
export * from './PreProgramVars'
|
||||
export * from './Request'
|
||||
export * from './Session'
|
||||
export * from './TreeNode'
|
||||
|
||||
41
api/src/utils/connectDB.ts
Normal file
41
api/src/utils/connectDB.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import path from 'path'
|
||||
import mongoose from 'mongoose'
|
||||
import { configuration } from '../../package.json'
|
||||
import { getDesktopFields } from '.'
|
||||
import { populateClients } from '../routes/api/auth'
|
||||
import shelljs from 'shelljs'
|
||||
|
||||
export const connectDB = async () => {
|
||||
shelljs.exec(`sasjs v`)
|
||||
|
||||
const { MODE } = process.env
|
||||
if (MODE?.trim() !== 'server') {
|
||||
console.log('Running in Destop Mode, no DB to connect.')
|
||||
|
||||
const { sasLoc, driveLoc } = await getDesktopFields()
|
||||
|
||||
process.sasLoc = sasLoc
|
||||
process.driveLoc = driveLoc
|
||||
|
||||
return
|
||||
} else {
|
||||
const { SAS_PATH } = process.env
|
||||
const sasDir = SAS_PATH ?? configuration.sasPath
|
||||
|
||||
process.sasLoc = path.join(sasDir, 'sas')
|
||||
}
|
||||
|
||||
console.log('sasLoc: ', process.sasLoc)
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import path from 'path'
|
||||
import { getRealPath } from '@sasjs/utils'
|
||||
|
||||
export const getWebBuildFolderPath = () =>
|
||||
getRealPath(path.join(__dirname, '..', '..', '..', 'web', 'build'))
|
||||
path.join(__dirname, '..', '..', '..', 'web', 'build')
|
||||
|
||||
export const getTmpFolderPath = () =>
|
||||
getRealPath(path.join(__dirname, '..', '..', 'tmp'))
|
||||
process.driveLoc ?? getRealPath(path.join(process.cwd(), 'tmp'))
|
||||
|
||||
export const getTmpFilesFolderPath = () =>
|
||||
path.join(getTmpFolderPath(), 'files')
|
||||
|
||||
7
api/src/utils/generateAccessToken.ts
Normal file
7
api/src/utils/generateAccessToken.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateAccessToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.ACCESS_TOKEN_SECRET as string, {
|
||||
expiresIn: '1h'
|
||||
})
|
||||
7
api/src/utils/generateAuthCode.ts
Normal file
7
api/src/utils/generateAuthCode.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateAuthCode = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.AUTH_CODE_SECRET as string, {
|
||||
expiresIn: '30s'
|
||||
})
|
||||
7
api/src/utils/generateRefreshToken.ts
Normal file
7
api/src/utils/generateRefreshToken.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { InfoJWT } from '../types'
|
||||
|
||||
export const generateRefreshToken = (data: InfoJWT) =>
|
||||
jwt.sign(data, process.env.REFRESH_TOKEN_SECRET as string, {
|
||||
expiresIn: '1day'
|
||||
})
|
||||
61
api/src/utils/getDesktopFields.ts
Normal file
61
api/src/utils/getDesktopFields.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import path from 'path'
|
||||
import { getString } from '@sasjs/utils/input'
|
||||
import { createFolder, fileExists, folderExists } from '@sasjs/utils'
|
||||
|
||||
const isWindows = () => process.platform === 'win32'
|
||||
|
||||
export const getDesktopFields = async () => {
|
||||
const sasLoc = await getSASLocation()
|
||||
const driveLoc = await getDriveLocation()
|
||||
|
||||
return { sasLoc, driveLoc }
|
||||
}
|
||||
|
||||
const getDriveLocation = async (): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!filePath) return 'Path to files/drive is required.'
|
||||
|
||||
const drivePath = path.join(process.cwd(), filePath)
|
||||
|
||||
if (!(await folderExists(drivePath))) {
|
||||
await createFolder(drivePath)
|
||||
await createFolder(path.join(drivePath, 'files'))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultLocation = isWindows() ? '.\\tmp\\' : './tmp/'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter path to file/drive (relative to executable): ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
return targetName
|
||||
}
|
||||
|
||||
const getSASLocation = async (): Promise<string> => {
|
||||
const validator = async (filePath: string) => {
|
||||
if (!filePath) return 'Path to SAS executable is required.'
|
||||
|
||||
if (!(await fileExists(filePath))) {
|
||||
return 'No file found at provided path.'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultLocation = isWindows()
|
||||
? 'C:\\Program Files\\SASHome\\SASFoundation\\9.4\\sas.exe'
|
||||
: '/opt/sas/sas9/SASHome/SASFoundation/9.4/sasexe/sas'
|
||||
|
||||
const targetName = await getString(
|
||||
'Please enter path to SAS executable (absolute path): ',
|
||||
validator,
|
||||
defaultLocation
|
||||
)
|
||||
|
||||
return targetName
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
export * from './connectDB'
|
||||
export * from './file'
|
||||
export * from './generateAccessToken'
|
||||
export * from './generateAuthCode'
|
||||
export * from './generateRefreshToken'
|
||||
export * from './getDesktopFields'
|
||||
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()
|
||||
}
|
||||
85
api/src/utils/validation.ts
Normal file
85
api/src/utils/validation.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
|
||||
export const getFileDriveValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
filePath: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const updateFileDriveValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
filePath: Joi.string().required(),
|
||||
fileContent: Joi.string().required()
|
||||
}).validate(data)
|
||||
|
||||
export const executeProgramRawValidation = (data: any): Joi.ValidationResult =>
|
||||
Joi.object({
|
||||
_program: Joi.string().required()
|
||||
})
|
||||
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
|
||||
.validate(data)
|
||||
27
api/src/utils/verifyTokenInDB.ts
Normal file
27
api/src/utils/verifyTokenInDB.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
displayName: dbUser.displayName,
|
||||
isAdmin: dbUser.isAdmin,
|
||||
isActive: dbUser.isActive
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"outDir": "./build",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"ts-node": {
|
||||
"files": true
|
||||
|
||||
42
api/tsoa.json
Normal file
42
api/tsoa.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"entryFile": "src/app.ts",
|
||||
"noImplicitAdditionalProperties": "throw-on-extras",
|
||||
"spec": {
|
||||
"outputDirectory": "public",
|
||||
"securityDefinitions": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "User",
|
||||
"description": "Operations about users"
|
||||
},
|
||||
{
|
||||
"name": "Client",
|
||||
"description": "Operations about clients"
|
||||
},
|
||||
{
|
||||
"name": "Auth",
|
||||
"description": "Operations about auth"
|
||||
},
|
||||
{
|
||||
"name": "Drive",
|
||||
"description": "Operations about drive"
|
||||
},
|
||||
{
|
||||
"name": "Group",
|
||||
"description": "Operations about group"
|
||||
},
|
||||
{
|
||||
"name": "STP",
|
||||
"description": "Operations about STP"
|
||||
}
|
||||
],
|
||||
"yaml": true,
|
||||
"specVersion": 3
|
||||
}
|
||||
}
|
||||
14
docker-compose.debug.yml
Normal file
14
docker-compose.debug.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
server:
|
||||
image: server
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 9229:9229
|
||||
command: ["node", "--inspect=0.0.0.0:9229", "./src/server.ts"]
|
||||
46
docker-compose.prod.yml
Normal file
46
docker-compose.prod.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
sasjs_server_prod:
|
||||
image: sasjs_server_prod
|
||||
build:
|
||||
context: .
|
||||
dockerfile: DockerfileProd
|
||||
environment:
|
||||
MODE: server
|
||||
CORS: disable
|
||||
PORT: ${PORT_API}
|
||||
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
|
||||
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
|
||||
AUTH_CODE_SECRET: ${AUTH_CODE_SECRET}
|
||||
DB_CONNECT: mongodb://mongodb:27017/sasjs
|
||||
SAS_PATH: /usr/server/sasexe
|
||||
expose:
|
||||
- ${PORT_API}
|
||||
ports:
|
||||
- ${PORT_API}:${PORT_API}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${SAS_EXEC}
|
||||
target: /usr/server/sasexe
|
||||
read_only: true
|
||||
links:
|
||||
- mongodb
|
||||
|
||||
mongodb:
|
||||
image: mongo:latest
|
||||
ports:
|
||||
- 27017:27017
|
||||
volumes:
|
||||
- data:/data/db
|
||||
mongo-seed-users:
|
||||
build: ./mongo-seed/users
|
||||
links:
|
||||
- mongodb
|
||||
mongo-seed-clients:
|
||||
build: ./mongo-seed/clients
|
||||
links:
|
||||
- mongodb
|
||||
|
||||
volumes:
|
||||
data:
|
||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
sasjs_server_api:
|
||||
image: sasjs_server_api
|
||||
build:
|
||||
context: .
|
||||
dockerfile: DockerfileApi
|
||||
environment:
|
||||
MODE: ${MODE}
|
||||
CORS: ${CORS}
|
||||
PORT: ${PORT_API}
|
||||
PORT_WEB: ${PORT_WEB}
|
||||
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
|
||||
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
|
||||
AUTH_CODE_SECRET: ${AUTH_CODE_SECRET}
|
||||
DB_CONNECT: mongodb://mongodb:27017/sasjs
|
||||
SAS_PATH: /usr/server/sasexe
|
||||
expose:
|
||||
- ${PORT_API}
|
||||
ports:
|
||||
- ${PORT_API}:${PORT_API}
|
||||
volumes:
|
||||
- ./api:/usr/server/api
|
||||
- type: bind
|
||||
source: ${SAS_EXEC}
|
||||
target: /usr/server/sasexe
|
||||
read_only: true
|
||||
links:
|
||||
- mongodb
|
||||
|
||||
sasjs_server_web:
|
||||
image: sasjs_server_web
|
||||
build: ./web
|
||||
environment:
|
||||
REACT_APP_PORT_API: ${PORT_API}
|
||||
PORT: ${PORT_WEB}
|
||||
expose:
|
||||
- ${PORT_WEB}
|
||||
ports:
|
||||
- ${PORT_WEB}:${PORT_WEB}
|
||||
volumes:
|
||||
- ./web:/usr/server/web
|
||||
|
||||
mongodb:
|
||||
image: mongo:latest
|
||||
ports:
|
||||
- 27017:27017
|
||||
volumes:
|
||||
- data:/data/db
|
||||
mongo-seed-users:
|
||||
build: ./mongo-seed/users
|
||||
links:
|
||||
- mongodb
|
||||
mongo-seed-clients:
|
||||
build: ./mongo-seed/clients
|
||||
links:
|
||||
- mongodb
|
||||
|
||||
volumes:
|
||||
data:
|
||||
4
mongo-seed/clients/Dockerfile
Normal file
4
mongo-seed/clients/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM mongo
|
||||
|
||||
COPY ./clients.json /clients.json
|
||||
CMD mongoimport --host mongodb --db sasjs --collection clients --type json --file /clients.json --jsonArray
|
||||
6
mongo-seed/clients/clients.json
Normal file
6
mongo-seed/clients/clients.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"clientId": "clientID1",
|
||||
"clientSecret": "clientSecret"
|
||||
}
|
||||
]
|
||||
4
mongo-seed/users/Dockerfile
Normal file
4
mongo-seed/users/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM mongo
|
||||
|
||||
COPY ./users.json /users.json
|
||||
CMD mongoimport --host mongodb --db sasjs --collection users --type json --file /users.json --jsonArray
|
||||
10
mongo-seed/users/users.json
Normal file
10
mongo-seed/users/users.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"displayName": "Super Admin",
|
||||
"username": "secretuser",
|
||||
"password": "$2a$10$hKvcVEZdhEQZCcxt6npazO6mY4jJkrzWvfQ5stdBZi8VTTwVMCVXO",
|
||||
"isAdmin": true,
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,13 +1,34 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 1,
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"prettier": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz",
|
||||
"integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"prettier": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.1.tgz",
|
||||
"integrity": "sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "0.0.1",
|
||||
"description": "NodeJS wrapper for calling the SAS binary executable",
|
||||
"scripts": {
|
||||
"server": "npm run server:prepare && npm run server:start",
|
||||
"server:prepare": "cd web && npm ci && npm run build && cd ../api && npm ci && cd ..",
|
||||
"server:start": "cd api && npm run start:prod",
|
||||
"lint-api:fix": "npx prettier --write \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint-api": "npx prettier --check \"api/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
"lint-web:fix": "npx prettier --write \"web/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
|
||||
57
routes.rest
Normal file
57
routes.rest
Normal file
@@ -0,0 +1,57 @@
|
||||
###
|
||||
POST http://localhost:5000/SASjsApi/drive/deploy
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDc2LCJleHAiOjE2MzU4OTA0NzZ9.Cx1F54ILgAUtnkit0Wg1K1YVO2RdNjOnTKdPhUtDm5I
|
||||
|
||||
###
|
||||
POST http://localhost:5000/SASjsApi/user
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"displayname": "User 2",
|
||||
"username": "username2",
|
||||
"password": "some password"
|
||||
}
|
||||
|
||||
###
|
||||
POST http://localhost:5000/SASjsApi/client
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InNlY3JldHVzZXIiLCJpc2FkbWluIjp0cnVlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODAzOTc3LCJleHAiOjE2MzU4OTAzNzd9.f-FLgLwryKvB5XrihdzaGZajO3d5E5OHEEuJI_03GRI
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "newClientID",
|
||||
"client_secret": "newClientSecret"
|
||||
}
|
||||
###
|
||||
POST https://sas.analytium.co.uk:5002/SASjsApi/auth/authorize
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "secretuser",
|
||||
"password": "secretpassword",
|
||||
"client_id": "clientID1"
|
||||
}
|
||||
|
||||
###
|
||||
POST http://localhost:5000/SASjsApi/auth/token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "clientID1",
|
||||
"client_secret": "clientID1secret",
|
||||
"code": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRJRDEiLCJ1c2VybmFtZSI6InVzZXJuYW1lMSIsImlzYWRtaW4iOmZhbHNlLCJpc2FjdGl2ZSI6dHJ1ZSwiaWF0IjoxNjM1ODA0MDYxLCJleHAiOjE2MzU4MDQwOTF9.jV7DpBWG7XAGODs22zAW_kWOqVLZvOxmmYJGpSNQ-KM"
|
||||
}
|
||||
|
||||
###
|
||||
DELETE http://localhost:5000/SASjsApi/auth/logout
|
||||
|
||||
|
||||
Users
|
||||
"username": "username1",
|
||||
"password": "some password",
|
||||
|
||||
"username": "username2",
|
||||
"password": "some password",
|
||||
Admins
|
||||
"username": "secretuser",
|
||||
"password": "secretpassword",
|
||||
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
build
|
||||
node_modules
|
||||
Dockerfile
|
||||
2
web/.env.example
Normal file
2
web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
REACT_APP_PORT_API=[place sasjs server port] default value is 5000
|
||||
REACT_APP_CLIENT_ID=<place clientId here>
|
||||
8
web/Dockerfile
Normal file
8
web/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM node:lts-alpine
|
||||
WORKDIR /usr/server/web
|
||||
COPY ["package.json","package-lock.json", "./"]
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# RUN chown -R node /usr/server/api
|
||||
# USER node
|
||||
CMD ["npm","start"]
|
||||
27991
web/package-lock.json
generated
27991
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,8 @@
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.16.6",
|
||||
"@types/react-router-dom": "^5.3.1",
|
||||
"babel-plugin-prismjs": "^2.1.0"
|
||||
"babel-plugin-prismjs": "^2.1.0",
|
||||
"prettier": "^2.4.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
|
||||
@@ -3,11 +3,28 @@ import { Route, HashRouter, Switch } from 'react-router-dom'
|
||||
import { ThemeProvider } from '@mui/material/styles'
|
||||
import { theme } from './theme'
|
||||
|
||||
import Login from './components/login'
|
||||
import Header from './components/header'
|
||||
import Home from './components/home'
|
||||
import Drive from './containers/Drive'
|
||||
import Studio from './containers/Studio'
|
||||
|
||||
import useTokens from './components/useTokens'
|
||||
|
||||
function App() {
|
||||
const { tokens, setTokens } = useTokens()
|
||||
|
||||
if (!tokens) {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<HashRouter>
|
||||
<Header />
|
||||
<Login setTokens={setTokens} />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<HashRouter>
|
||||
|
||||
94
web/src/components/login.tsx
Normal file
94
web/src/components/login.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import { CssBaseline, Box, TextField, Button } from '@mui/material'
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
const { NODE_ENV, REACT_APP_PORT_API } = process.env
|
||||
const baseUrl =
|
||||
NODE_ENV === 'development'
|
||||
? `http://localhost:${REACT_APP_PORT_API ?? 5000}`
|
||||
: ''
|
||||
|
||||
const getAuthCode = async (credentials: any) => {
|
||||
return fetch(`${baseUrl}/SASjsApi/auth/authorize`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(credentials)
|
||||
}).then((data) => data.json())
|
||||
}
|
||||
const getTokens = async (payload: any) => {
|
||||
return fetch(`${baseUrl}/SASjsApi/auth/token`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
}).then((data) => data.json())
|
||||
}
|
||||
|
||||
const Login = ({ setTokens }: any) => {
|
||||
const [username, setUserName] = useState()
|
||||
const [password, setPassword] = useState()
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault()
|
||||
const { REACT_APP_CLIENT_ID: clientId } = process.env
|
||||
|
||||
const { code } = await getAuthCode({
|
||||
clientId,
|
||||
username,
|
||||
password
|
||||
})
|
||||
|
||||
const { accessToken, refreshToken } = await getTokens({
|
||||
clientId,
|
||||
code
|
||||
})
|
||||
|
||||
setTokens(accessToken, refreshToken)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="main"
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={{
|
||||
'& > :not(style)': { m: 1, width: '25ch' }
|
||||
}}
|
||||
>
|
||||
<CssBaseline />
|
||||
<br />
|
||||
<h2>Welcome to SASjs Server!</h2>
|
||||
<br />
|
||||
|
||||
<TextField
|
||||
id="username"
|
||||
label="Username"
|
||||
type="text"
|
||||
variant="outlined"
|
||||
onChange={(e: any) => setUserName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
id="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
onChange={(e: any) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outlined">
|
||||
Submit
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Login.propTypes = {
|
||||
setTokens: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default Login
|
||||
102
web/src/components/useTokens.ts
Normal file
102
web/src/components/useTokens.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function useTokens() {
|
||||
const getTokens = () => {
|
||||
const accessTokenString = localStorage.getItem('accessToken')
|
||||
const accessToken: string = accessTokenString
|
||||
? JSON.parse(accessTokenString)
|
||||
: undefined
|
||||
|
||||
const refreshTokenString = localStorage.getItem('refreshToken')
|
||||
const refreshToken: string = refreshTokenString
|
||||
? JSON.parse(refreshTokenString)
|
||||
: undefined
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
setAxiosRequestHeader(accessToken)
|
||||
return { accessToken, refreshToken }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [tokens, setTokens] = useState(getTokens())
|
||||
|
||||
useEffect(() => {
|
||||
if (tokens === undefined) {
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
}, [tokens])
|
||||
setAxiosResponse(setTokens)
|
||||
|
||||
const saveTokens = (accessToken: string, refreshToken: string) => {
|
||||
localStorage.setItem('accessToken', JSON.stringify(accessToken))
|
||||
localStorage.setItem('refreshToken', JSON.stringify(refreshToken))
|
||||
setAxiosRequestHeader(accessToken)
|
||||
setTokens({ accessToken, refreshToken })
|
||||
}
|
||||
|
||||
return {
|
||||
setTokens: saveTokens,
|
||||
tokens
|
||||
}
|
||||
}
|
||||
|
||||
const { NODE_ENV, REACT_APP_PORT_API } = process.env
|
||||
const baseUrl =
|
||||
NODE_ENV === 'development'
|
||||
? `http://localhost:${REACT_APP_PORT_API ?? 5000}`
|
||||
: ''
|
||||
|
||||
const isAbsoluteURLRegex = /^(?:\w+:)\/\//
|
||||
|
||||
const setAxiosRequestHeader = (accessToken: string) => {
|
||||
axios.interceptors.request.use(function (config) {
|
||||
if (baseUrl && !isAbsoluteURLRegex.test(config.url as string)) {
|
||||
config.url = baseUrl + config.url
|
||||
}
|
||||
config.headers!['Authorization'] = `Bearer ${accessToken}`
|
||||
config.withCredentials = true
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
const setAxiosResponse = (setTokens: Function) => {
|
||||
// Add a response interceptor
|
||||
axios.interceptors.response.use(
|
||||
function (response) {
|
||||
// Any status code that lie within the range of 2xx cause this function to trigger
|
||||
return response
|
||||
},
|
||||
async function (error) {
|
||||
if (error.response?.status === 401) {
|
||||
// refresh token
|
||||
// const { accessToken, refreshToken: newRefresh } = await refreshMyToken(
|
||||
// refreshToken
|
||||
// )
|
||||
|
||||
// if (accessToken && newRefresh) {
|
||||
// setTokens(accessToken, newRefresh)
|
||||
// error.config.headers['Authorization'] = 'Bearer ' + accessToken
|
||||
// error.config.baseURL = undefined
|
||||
|
||||
// return axios.request(error.config)
|
||||
// }
|
||||
setTokens(undefined)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// const refreshMyToken = async (refreshToken: string) => {
|
||||
// return fetch('http://localhost:5000/SASjsApi/auth/refresh', {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${refreshToken}`
|
||||
// }
|
||||
// }).then((data) => data.json())
|
||||
// }
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -20,7 +16,5 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user