1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 01:14:36 +00:00

Compare commits

...

85 Commits

Author SHA1 Message Date
Yury Shkoda
01d76fa66f Merge pull request #574 from sasjs/sasjs/server
Add sasjs/server support
2021-10-28 15:36:45 +03:00
Yury Shkoda
49cfde9f7d chore(sasjs/server): fix deploy endpoint 2021-10-28 15:13:14 +03:00
Yury Shkoda
ce04ffea05 fix(SASjsApiClient): change SASjs Server endpoints 2021-10-28 10:21:15 +03:00
Yury Shkoda
0457eb6663 fix: fix calls to SASjsApi endpoints 2021-10-27 11:16:35 +03:00
Yury Shkoda
519494718b chore: address PR comments 2021-10-27 11:15:28 +03:00
Yury Shkoda
0321f77451 chore: update dependencies 2021-10-20 15:08:58 +03:00
Yury Shkoda
6c5fdc01eb chore: merge branch 'sasjs/server' of https://github.com/sasjs/adapter into sasjs/server 2021-10-20 15:05:03 +03:00
Yury Shkoda
2aa0cd8d7a chore: remove tmp utilities 2021-10-20 15:01:21 +03:00
Yury Shkoda
397bc4524f chore: change 'SASBase' to 'SASjs' 2021-10-20 15:00:52 +03:00
Yury Shkoda
8dce9f3e48 chore(npm): update @sasjs/utils version 2021-10-20 14:59:02 +03:00
8e9f1df1ce chore: fixing the error when using in angular, added isNode check for imports 2021-10-12 16:45:39 +02:00
Yury Shkoda
90b11fe3fa feat(deploy): add appLoc 2021-10-04 17:01:25 +03:00
Yury Shkoda
147609842d Merge pull request #565 from sasjs/modifying-npmignore
chore: add .all-contributorsrc to .npmignore
2021-10-04 09:17:54 +03:00
Yury Shkoda
dd6f9cd617 chore(npm): add empty line to the end of .npmignore 2021-10-04 09:10:37 +03:00
Vladislav Parhomchik
a38de108e3 chore: add .all-contributorsrc to .npmignore 2021-10-01 10:31:30 +03:00
Yury Shkoda
d418a7e971 fix(http): extend valid responce statuses up to 400 2021-09-30 14:33:33 +03:00
Yury Shkoda
a5b5052a5f fix(baseSAS): removed sasjs/server logic 2021-09-30 14:31:54 +03:00
Yury Shkoda
7638595523 chore(git): fix .gitignore 2021-09-30 14:30:47 +03:00
Allan Bowe
70d64f6eec Merge pull request #556 from sasjs/test-framework
chore: bump up test-framework
2021-09-29 22:42:54 +01:00
Yury Shkoda
5f3416ecd7 chore(utils): add tmpFolder utils 2021-09-28 10:39:54 +03:00
Yury Shkoda
d8b1a72da2 chore(types): add FileTree types 2021-09-28 10:39:18 +03:00
Yury Shkoda
7e64819eb2 feat(sasjs/server): add SASBaseApiClient class 2021-09-28 10:38:29 +03:00
Yury Shkoda
2f1d403af4 chore(deps): use @sasjs/utils tarball 2021-09-28 10:33:11 +03:00
Yury Shkoda
075d410f7d chore(git): ignore tmp folder 2021-09-28 10:31:36 +03:00
Muhammad Saad
f964bcef9e Merge pull request #559 from sasjs/job-executor-bugs
fix: Job executor bugs
2021-09-22 20:08:17 +05:00
Allan Bowe
5784232d4e chore: updating readme 2021-09-20 17:50:18 +00:00
Yury Shkoda
70ecc8b50e Merge pull request #553 from sasjs/update-deps
chore(deps): update dependencies
2021-09-20 15:48:57 +03:00
Saad Jutt
369a035e8a fix(webJobExecutor): append resend request for viya's getJobUri authentication 2021-09-20 13:28:41 +05:00
Saad Jutt
e5655033c1 fix(jobExecutor): appending resend requests before login call 2021-09-20 12:52:49 +05:00
Yury Shkoda
c7af30bfa3 chore(deps): add caret to axios version 2021-09-17 16:36:22 +03:00
Yury Shkoda
c8da3a54cf chore(axios): override default request transformer 2021-09-17 16:29:15 +03:00
Yury Shkoda
100da16803 chore(deps): bump axios to 0.21.4 2021-09-17 16:28:44 +03:00
Allan Bowe
dc91679040 Merge pull request #558 from sasjs/issue-554
fix: viya debug response null due to wrong content-type
2021-09-17 15:18:58 +03:00
28c8ebfc65 fix: viya debug response null due to wrong content-type 2021-09-16 14:58:18 +02:00
Yury Shkoda
0c4d30afe3 Merge pull request #557 from sasjs/dependabot-upd
chore(dependabot): change schedule interval
2021-09-16 14:18:27 +03:00
Yury Shkoda
bc015b72b6 chore(dependabot): change schedule interval 2021-09-16 14:08:53 +03:00
085a3f84e9 chore: bump up test-framework 2021-09-16 10:37:56 +02:00
Yury Shkoda
f241d75f0a chore(deps): pin axios version to 0.21.1 2021-09-14 17:23:19 +03:00
Muhammad Saad
8a883c09f6 Merge pull request #549 from sasjs/improvements-file-uploader
fix: FileUploader extends BaseJobExecutor
2021-09-14 18:47:26 +05:00
Vladislav Parhomchik
42d01b4044 chore(deps): update dependencies 2021-09-14 11:09:47 +03:00
Saad Jutt
15ff90025a fix(fileUploader): added loginCallback 2021-09-14 05:58:40 +05:00
Saad Jutt
10cf4998f5 fix(loginPrompt): z-index added 2021-09-13 18:02:26 +05:00
Saad Jutt
f714f20f29 chore(fileUploader): support loginCallback and re-submit request 2021-09-13 17:45:35 +05:00
Saad Jutt
19adcc3115 chore: FileUploader extends BaseJobExecutor 2021-09-13 17:42:41 +05:00
Muhammad Saad
bb6b25bac7 Merge pull request #548 from sasjs/code-improvements
chore(imprvements): code changes for fileUploader
2021-09-13 17:40:00 +05:00
Saad Jutt
ec9dbd7ad6 chore(imprvements): code changes for fileUploader 2021-09-13 07:30:26 +05:00
Allan Bowe
2cfba99bda Merge pull request #540 from sasjs/issue-532
fix: move SASjsRequest array from BaseJobExecutor class to RequestClient class
2021-09-11 17:17:33 +03:00
Allan Bowe
a181914c36 Merge pull request #522 from sasjs/redirected-login
feat(auth): redirected login
2021-09-10 18:03:24 +03:00
Saad Jutt
539405e249 chore: fixed sasjs-tests build 2021-09-09 15:30:12 +05:00
Krishna Acondy
d9c27efa8d chore(auth-manager): rename variable 2021-09-09 07:51:07 +01:00
Krishna Acondy
2ccc7b5499 chore(request-client): rename method 2021-09-09 07:41:20 +01:00
Saad Jutt
4623b9665b chore: login prompt dialog bug fixed 2021-09-09 11:29:12 +05:00
9c099b899c fix: If the request client has already been instantiated, update config 2021-09-09 11:03:48 +05:00
Saad Jutt
3ae0809ee5 test(AuthManager): improved coverage 2021-09-09 04:37:30 +05:00
d52c5b26a0 chore: merge master into issue-532 2021-09-08 15:16:06 +05:00
Saad Jutt
0ea6e839ac test: added for verifySasLogin 2021-09-08 15:08:55 +05:00
46ef7b19f6 fix: before instantiating RequestClient check if its already instantiated 2021-09-08 15:02:22 +05:00
Saad Jutt
a00bf5ba67 test(AuthManager): specs added for redirected login 2021-09-08 13:04:03 +05:00
Saad Jutt
e0b09adbba chore: code refactor renamed variables/functions 2021-09-08 06:30:53 +05:00
Saad Jutt
19a57dbf6e chore: code refactor renamed variables/functions 2021-09-08 05:49:24 +05:00
Saad Jutt
cd2b32f2f4 test(checkSession): extract username from server response 2021-09-07 06:08:17 +05:00
Saad Jutt
a1f5355d6a chore: fetch username for Redirected-Login and return 2021-09-07 05:26:42 +05:00
Saad Jutt
0972c0deaa chore(merge): Merge branch 'master' into redirected-login 2021-09-07 05:05:51 +05:00
Allan Bowe
e1a5cc9e45 Merge pull request #504 from sasjs/extract-username-while-check-session
fix: while checking session extract username also
2021-09-06 16:22:04 +03:00
Saad Jutt
351a22cb3c fix: deriving username with full name 2021-09-06 12:52:40 +05:00
Saad Jutt
3ccd35a4e2 fix: if username is not present in SAS9, derive it with full name 2021-09-06 12:42:26 +05:00
Saad Jutt
e4956cc1d4 chore: test corrected for AuthManager 2021-09-05 12:06:27 +05:00
Saad Jutt
291ba51b07 fix(request): handled error case for sas9 server 2021-09-05 11:55:17 +05:00
Saad Jutt
f40a86f0f6 chore(redirectLogin): onLoggedOut callback should be an async 2021-09-02 13:43:07 +05:00
Saad Jutt
867422f4cc fix: extraResponseAttributes for WebJobExecutor + sasjs-tests 2021-09-01 03:50:55 +05:00
Saad Jutt
2a6e29b5b8 fix: username returns from checkSession 2021-09-01 03:49:44 +05:00
Saad Jutt
e4d669f9b6 chore: typo fixed userName 2021-08-31 12:58:47 +05:00
Saad Jutt
5edf09e0a7 fix: usernames to lower case 2021-08-31 12:50:04 +05:00
Saad Jutt
5a695f495c chore: login returns username 2021-08-31 12:39:04 +05:00
Saad Jutt
f231edb4a6 chore: redirect login with onLoggedOut callback 2021-08-31 12:36:20 +05:00
Saad Jutt
389ef94cd5 feat(login): redirect mechanism - in page link to open popup 2021-08-28 10:01:20 +05:00
Saad Jutt
4c90f66dbc chore(merge): Merge branch 'master' into redirected-login 2021-08-27 23:34:52 +05:00
ab8643a89a chore: merge master into extract-username-while-check-session 2021-08-27 14:17:20 +05:00
Saad Jutt
83353326fb test(AuthManager): fixed 2021-08-26 10:44:06 +05:00
Saad Jutt
db7a5d601e fix(login): code refactor + sasjs-tests updated 2021-08-26 10:33:00 +05:00
Saad Jutt
ee977f4fab chore(merge): Merge branch 'master' into extract-username-while-check-session 2021-08-26 09:02:40 +05:00
Saad Jutt
1a59f95be7 fix: split code to files + corrected usage of loginCallback 2021-08-25 07:55:04 +05:00
Saad Jutt
97918f301b chore(redirectLogin): centered popup + verifying sas9 login + sasviya login fixes 2021-08-22 03:57:23 +05:00
Krishna Acondy
830a907bd1 feat(login): add redirected login mechanism 2021-08-21 21:36:50 +01:00
Saad Jutt
fc1c93957c fix: while checking session extract username also 2021-08-07 07:32:48 +05:00
41 changed files with 3281 additions and 23323 deletions

View File

@@ -1,7 +1,7 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
open-pull-requests-limit: 10

2
.gitignore vendored
View File

@@ -5,4 +5,4 @@ build
/coverage
.DS_Store
.DS_Store

View File

@@ -3,3 +3,4 @@ docs/
.github/
*.md
*.spec.ts
.all-contributorsrc

View File

@@ -36,7 +36,7 @@ Ok ok. Deploy this [example.html](https://raw.githubusercontent.com/sasjs/adapte
The backend part can be deployed as follows:
```
```sas
%let appLoc=/Public/app/readme; /* Metadata or Viya Folder per SASjs config */
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc; /* compile macros (can also be downloaded & compiled seperately) */
@@ -85,11 +85,11 @@ let sasJs = new SASjs.default(
);
```
If you've installed it via NPM, you can import it as a default import like so:
```
```js
import SASjs from '@sasjs/adapter';
```
You can then instantiate it with:
```
```js
const sasJs = new SASjs({your config})
```
@@ -119,6 +119,7 @@ sasJs.request("/path/to/my/service", dataObject)
console.log(response.tablewith2cols1row[0].COL1.value)
})
```
We supply the path to the SAS service, and a data object. The data object can be null (for services with no input), or can contain one or more tables in the following format:
```javascript
@@ -146,6 +147,7 @@ The adapter will also cache the logs (if debug enabled) and even the work tables
The SAS side is handled by a number of macros in the [macro core](https://github.com/sasjs/core) library.
The following snippet shows the process of SAS tables arriving / leaving:
```sas
/* fetch all input tables sent from frontend - they arrive as work tables */
%webout(FETCH)
@@ -161,7 +163,6 @@ run;
%webout(OBJ,tables,fmt=N) /* unformatted (raw) data */
%webout(OBJ,tables,label=newtable) /* rename tables on export */
%webout(CLOSE) /* close the JSON and send some extra useful variables too */
```
## Configuration
@@ -172,6 +173,7 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9` or `SASVIYA`.
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned.
* `LoginMechanism` - either `Default` or `Redirected`. If `Redirected` then authentication occurs through the injection of an additional screen, which contains the SASLogon prompt. This allows for more complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the redirect flow can also be modified. If left at "Default" then the developer must capture the username and password and use these with the `.login()` method.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
@@ -196,7 +198,7 @@ Here we are running Jobs using the Job Execution Service except this time we are
This approach (`useComputeApi: false`) also ensures that jobs are displayed in Environment Manager.
```
```json
{
appLoc:"/Your/Path",
serverType:"SASVIYA",
@@ -210,12 +212,12 @@ This approach is by far the fastest, as a result of the optimisations we have bu
With this approach (`useComputeApi: true`), the requests/logs will _not_ appear in the list in Environment manager.
```
```json
{
appLoc:"/Your/Path",
serverType:"SASVIYA",
useComputeApi: true,
contextName: 'yourComputeContext'
contextName: "yourComputeContext"
}
```

2179
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,29 +48,29 @@
"copyfiles": "^2.4.1",
"cp": "^0.2.0",
"dotenv": "^10.0.0",
"jest": "^27.1.0",
"jest": "^27.2.0",
"jest-extended": "^0.11.5",
"node-polyfill-webpack-plugin": "^1.1.4",
"path": "^0.12.7",
"process": "^0.11.10",
"rimraf": "^3.0.2",
"semantic-release": "^17.4.7",
"terser-webpack-plugin": "^5.2.0",
"terser-webpack-plugin": "^5.2.4",
"ts-jest": "^27.0.3",
"ts-loader": "^9.2.2",
"tslint": "^6.1.3",
"tslint-config-prettier": "^1.18.0",
"typedoc": "^0.21.9",
"typedoc": "^0.22.3",
"typedoc-neo-theme": "^1.1.1",
"typedoc-plugin-external-module-name": "^4.0.6",
"typescript": "4.3.5",
"webpack": "^5.44.0",
"webpack": "^5.52.1",
"webpack-cli": "^4.7.2"
},
"main": "index.js",
"dependencies": {
"@sasjs/utils": "^2.30.0",
"axios": "^0.21.1",
"@sasjs/utils": "^2.32.0",
"axios": "^0.21.4",
"axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0",
"https": "^1.0.0",

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"private": true,
"dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
"@sasjs/test-framework": "^1.4.0",
"@sasjs/test-framework": "^1.4.2",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/react": "^17.0.1",

View File

@@ -1,4 +1,4 @@
import SASjs, { SASjsConfig } from '@sasjs/adapter'
import SASjs, { LoginMechanism, SASjsConfig } from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import { ServerType } from '@sasjs/utils/types'
@@ -13,7 +13,8 @@ const defaultConfig: SASjsConfig = {
debug: false,
contextName: 'SAS Job Execution compute context',
useComputeApi: false,
allowInsecureRequests: false
allowInsecureRequests: false,
loginMechanism: LoginMechanism.Default
}
const customConfig = {
@@ -41,6 +42,19 @@ export const basicTests = (
assertion: (response: any) =>
response && response.isLoggedIn && response.userName === userName
},
{
title: 'Fetch username for already logged in user',
description: 'Should log the user in',
test: async () => {
await adapter.logIn(userName, password)
const newAdapterIns = new SASjs(adapter.getSasjsConfig())
return await newAdapterIns.checkSession()
},
assertion: (response: any) =>
response?.isLoggedIn && response?.userName === userName
},
{
title: 'Multiple Log in attempts',
description:
@@ -48,7 +62,7 @@ export const basicTests = (
test: async () => {
await adapter.logOut()
await adapter.logIn('invalid', 'invalid')
return adapter.logIn(userName, password)
return await adapter.logIn(userName, password)
},
assertion: (response: any) =>
response && response.isLoggedIn && response.userName === userName
@@ -64,8 +78,8 @@ export const basicTests = (
'common/sendArr',
stringData,
undefined,
() => {
adapter.logIn(userName, password)
async () => {
await adapter.logIn(userName, password)
}
)
},
@@ -151,7 +165,7 @@ export const basicTests = (
description:
'Should complete successful request with extra attributes present in response',
test: async () => {
const config = {
const config: Partial<SASjsConfig> = {
useComputeApi: false
}

View File

@@ -1,99 +0,0 @@
import { isUrl, getValidJson, parseSasViyaDebugResponse } from './utils'
import { UploadFile } from './types/UploadFile'
import { ErrorResponse, LoginRequiredError } from './types/errors'
import { RequestClient } from './request/RequestClient'
import { ServerType } from '@sasjs/utils/types'
import SASjs from './SASjs'
import { Server } from 'https'
import { SASjsConfig } from './types'
import { config } from 'process'
export class FileUploader {
constructor(
private sasjsConfig: SASjsConfig,
private jobsPath: string,
private requestClient: RequestClient
) {
if (this.sasjsConfig.serverUrl) isUrl(this.sasjsConfig.serverUrl)
}
public uploadFile(sasJob: string, files: UploadFile[], params: any) {
if (files?.length < 1)
return Promise.reject(
new ErrorResponse('At least one file must be provided.')
)
if (!sasJob || sasJob === '')
return Promise.reject(new ErrorResponse('sasJob must be provided.'))
let paramsString = ''
for (let param in params) {
if (params.hasOwnProperty(param)) {
paramsString += `&${param}=${params[param]}`
}
}
const program = this.sasjsConfig.appLoc
? this.sasjsConfig.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
const csrfToken = this.requestClient.getCsrfToken('file')
if (csrfToken) formData.append('_csrf', csrfToken.value)
if (this.sasjsConfig.debug) formData.append('_debug', '131')
if (
this.sasjsConfig.serverType === ServerType.SasViya &&
this.sasjsConfig.contextName
)
formData.append('_contextname', this.sasjsConfig.contextName)
const headers = {
'cache-control': 'no-cache',
Accept: '*/*',
'Content-Type': 'text/plain'
}
// currently only web approach is supported for file upload
// therefore log is part of response with debug enabled and must be parsed
return this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers)
.then(async (res) => {
this.requestClient!.appendRequest(res, sasJob, this.sasjsConfig.debug)
if (
this.sasjsConfig.serverType === ServerType.SasViya &&
this.sasjsConfig.debug
) {
const jsonResponse = await parseSasViyaDebugResponse(
res.result as string,
this.requestClient,
this.sasjsConfig.serverUrl
)
return jsonResponse
}
return typeof res.result === 'string'
? getValidJson(res.result)
: res.result
//TODO: append to SASjs requests
})
.catch((err: Error) => {
if (err instanceof LoginRequiredError) {
return Promise.reject(
new ErrorResponse('You must be logged in to upload a file.', err)
)
}
return Promise.reject(
new ErrorResponse('File upload request failed.', err)
)
})
}
}

View File

@@ -1,8 +1,17 @@
import { compareTimestamps, asyncForEach } from './utils'
import { SASjsConfig, UploadFile, EditContextInput, PollOptions } from './types'
import {
SASjsConfig,
UploadFile,
EditContextInput,
PollOptions,
LoginMechanism,
FolderMember,
ServiceMember,
ExecutionQuery
} from './types'
import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient'
import { FileUploader } from './FileUploader'
import { SASjsApiClient } from './SASjsApiClient'
import { AuthManager } from './auth'
import {
ServerType,
@@ -16,9 +25,11 @@ import {
WebJobExecutor,
ComputeJobExecutor,
JesJobExecutor,
Sas9JobExecutor
Sas9JobExecutor,
FileUploader
} from './job-execution'
import { ErrorResponse } from './types/errors'
import { LoginOptions, LoginResult } from './types/Login'
const defaultConfig: SASjsConfig = {
serverUrl: '',
@@ -29,7 +40,8 @@ const defaultConfig: SASjsConfig = {
debug: false,
contextName: 'SAS Job Execution compute context',
useComputeApi: null,
allowInsecureRequests: false
allowInsecureRequests: false,
loginMechanism: LoginMechanism.Default
}
/**
@@ -41,6 +53,7 @@ export default class SASjs {
private jobsPath: string = ''
private sasViyaApiClient: SASViyaApiClient | null = null
private sas9ApiClient: SAS9ApiClient | null = null
private SASjsApiClient: SASjsApiClient | null = null
private fileUploader: FileUploader | null = null
private authManager: AuthManager | null = null
private requestClient: RequestClient | null = null
@@ -499,7 +512,7 @@ export default class SASjs {
...this.sasjsConfig,
...config
}
await this.setupConfiguration()
this.setupConfiguration()
}
/**
@@ -526,8 +539,27 @@ export default class SASjs {
* @param username - a string representing the username.
* @param password - a string representing the password.
*/
public async logIn(username: string, password: string) {
return this.authManager!.logIn(username, password)
public async logIn(
username?: string,
password?: string,
options: LoginOptions = {}
): Promise<LoginResult> {
if (this.sasjsConfig.loginMechanism === LoginMechanism.Default) {
if (!username || !password) {
throw new Error(
'A username and password are required when using the default login mechanism.'
)
}
return this.authManager!.logIn(username, password)
}
if (typeof window === typeof undefined) {
throw new Error(
'The redirected login mechanism is only available for use in the browser.'
)
}
return this.authManager!.redirectedLogIn(options)
}
/**
@@ -544,24 +576,32 @@ export default class SASjs {
* Process). Is prepended at runtime with the value of `appLoc`.
* @param files - array of files to be uploaded, including File object and file name.
* @param params - request URL parameters.
* @param overrideSasjsConfig - object to override existing config (optional)
* @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config,
* for that particular function call.
* @param loginRequiredCallback - a function that is called if the
* user is not logged in (eg to display a login form). The request will be
* resubmitted after successful login.
*/
public uploadFile(
public async uploadFile(
sasJob: string,
files: UploadFile[],
params: any,
overrideSasjsConfig?: any
params: { [key: string]: any } | null,
config: { [key: string]: any } = {},
loginRequiredCallback?: () => any
) {
const fileUploader = overrideSasjsConfig
? new FileUploader(
{ ...this.sasjsConfig, ...overrideSasjsConfig },
this.jobsPath,
this.requestClient!
)
: this.fileUploader ||
new FileUploader(this.sasjsConfig, this.jobsPath, this.requestClient!)
config = {
...this.sasjsConfig,
...config
}
const data = { files, params }
return fileUploader.uploadFile(sasJob, files, params)
return await this.fileUploader!.execute(
sasJob,
data,
config,
loginRequiredCallback
)
}
/**
@@ -789,6 +829,14 @@ export default class SASjs {
)
}
public async deployToSASjs(members: [FolderMember, ServiceMember]) {
return await this.SASjsApiClient?.deploy(members, this.sasjsConfig.appLoc)
}
public async executeJobSASjs(query: ExecutionQuery) {
return await this.SASjsApiClient?.executeJob(query)
}
/**
* Kicks off execution of the given job via the compute API.
* @returns an object representing the compute session created for the given job.
@@ -847,6 +895,7 @@ export default class SASjs {
await this.webJobExecutor?.resendWaitingRequests()
await this.computeJobExecutor?.resendWaitingRequests()
await this.jesJobExecutor?.resendWaitingRequests()
await this.fileUploader?.resendWaitingRequests()
}
/**
@@ -912,10 +961,17 @@ export default class SASjs {
this.sasjsConfig.serverUrl = this.sasjsConfig.serverUrl.slice(0, -1)
}
this.requestClient = new RequestClient(
this.sasjsConfig.serverUrl,
this.sasjsConfig.allowInsecureRequests
)
if (!this.requestClient) {
this.requestClient = new RequestClient(
this.sasjsConfig.serverUrl,
this.sasjsConfig.allowInsecureRequests
)
} else {
this.requestClient.setConfig(
this.sasjsConfig.serverUrl,
this.sasjsConfig.allowInsecureRequests
)
}
this.jobsPath =
this.sasjsConfig.serverType === ServerType.SasViya
@@ -930,34 +986,49 @@ export default class SASjs {
)
if (this.sasjsConfig.serverType === ServerType.SasViya) {
if (this.sasViyaApiClient)
if (this.sasViyaApiClient) {
this.sasViyaApiClient!.setConfig(
this.sasjsConfig.serverUrl,
this.sasjsConfig.appLoc
)
else
} else {
this.sasViyaApiClient = new SASViyaApiClient(
this.sasjsConfig.serverUrl,
this.sasjsConfig.appLoc,
this.sasjsConfig.contextName,
this.requestClient
)
}
this.sasViyaApiClient.debug = this.sasjsConfig.debug
}
if (this.sasjsConfig.serverType === ServerType.Sas9) {
if (this.sas9ApiClient)
if (this.sas9ApiClient) {
this.sas9ApiClient!.setConfig(this.sasjsConfig.serverUrl)
else
} else {
this.sas9ApiClient = new SAS9ApiClient(
this.sasjsConfig.serverUrl,
this.jobsPath,
this.sasjsConfig.allowInsecureRequests
)
}
}
if (this.sasjsConfig.serverType === ServerType.Sasjs) {
if (this.SASjsApiClient) {
this.SASjsApiClient.setConfig(this.sasjsConfig.serverUrl)
} else {
this.SASjsApiClient = new SASjsApiClient(
this.sasjsConfig.serverUrl,
this.requestClient
)
}
}
this.fileUploader = new FileUploader(
this.sasjsConfig,
this.sasjsConfig.serverUrl,
this.sasjsConfig.serverType!,
this.jobsPath,
this.requestClient
)

39
src/SASjsApiClient.ts Normal file
View File

@@ -0,0 +1,39 @@
import { FolderMember, ServiceMember, ExecutionQuery } from './types'
import { RequestClient } from './request/RequestClient'
export class SASjsApiClient {
constructor(
private serverUrl: string,
private requestClient: RequestClient
) {}
public setConfig(serverUrl: string) {
if (serverUrl) this.serverUrl = serverUrl
}
public async deploy(members: [FolderMember, ServiceMember], appLoc: string) {
const { result } = await this.requestClient.post<{
status: string
message: string
example?: {}
}>(
'SASjsApi/drive/deploy',
{ fileTree: members, appLoc: appLoc },
undefined
)
return Promise.resolve(result)
}
public async executeJob(query: ExecutionQuery) {
const { result } = await this.requestClient.post<{
status: string
message: string
log?: string
logPath?: string
error?: {}
}>('SASjsApi/stp/execute', query, undefined)
return Promise.resolve(result)
}
}

View File

@@ -4,7 +4,7 @@ import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream } from '../../types'
import { isNode } from '../../utils'
import { delay, isNode } from '../../utils'
export async function pollJobState(
requestClient: RequestClient,
@@ -246,5 +246,3 @@ const doPoll = async (
return { state, pollCount }
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -1,11 +1,16 @@
import { ServerType } from '@sasjs/utils/types'
import { RequestClient } from '../request/RequestClient'
import { LoginOptions, LoginResult } from '../types/Login'
import { serialize } from '../utils'
import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login'
import { verifySasViyaLogin } from './verifySasViyaLogin'
export class AuthManager {
public userName = ''
private loginUrl: string
private logoutUrl: string
private redirectedLoginUrl = `/SASLogon/home`
constructor(
private serverUrl: string,
private serverType: ServerType,
@@ -19,65 +24,137 @@ export class AuthManager {
: '/SASLogon/logout.do?'
}
/**
* Opens Pop up window to SAS Login screen.
* And checks if user has finished login process.
*/
public async redirectedLogIn({
onLoggedOut
}: LoginOptions): Promise<LoginResult> {
const { isLoggedIn: isLoggedInAlready, userName: currentSessionUsername } =
await this.fetchUserName()
if (isLoggedInAlready) {
await this.loginCallback()
return {
isLoggedIn: true,
userName: currentSessionUsername
}
}
const loginPopup = await openWebPage(
this.redirectedLoginUrl,
'SASLogon',
{
width: 500,
height: 600
},
onLoggedOut
)
if (!loginPopup) {
return { isLoggedIn: false, userName: '' }
}
const { isLoggedIn } =
this.serverType === ServerType.SasViya
? await verifySasViyaLogin(loginPopup)
: await verifySas9Login(loginPopup)
loginPopup.close()
if (isLoggedIn) {
if (this.serverType === ServerType.Sas9) {
await this.performCASSecurityCheck()
}
const { userName } = await this.fetchUserName()
await this.loginCallback()
return { isLoggedIn: true, userName }
}
return { isLoggedIn: false, userName: '' }
}
/**
* Logs into the SAS server with the supplied credentials.
* @param username - a string representing the username.
* @param password - a string representing the password.
* @returns - a boolean `isLoggedin` and a string `username`
*/
public async logIn(username: string, password: string) {
const loginParams: any = {
public async logIn(username: string, password: string): Promise<LoginResult> {
const loginParams = {
_service: 'default',
username,
password
}
this.userName = loginParams.username
let {
isLoggedIn: isLoggedInAlready,
loginForm,
userName: currentSessionUsername
} = await this.checkSession()
const { isLoggedIn, loginForm } = await this.checkSession()
if (isLoggedInAlready) {
if (currentSessionUsername === loginParams.username) {
await this.loginCallback()
if (isLoggedIn) {
await this.loginCallback()
return {
isLoggedIn,
userName: this.userName
this.userName = currentSessionUsername!
return {
isLoggedIn: true,
userName: this.userName
}
} else {
await this.logOut()
loginForm = await this.getNewLoginForm()
}
}
} else this.userName = ''
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let loggedIn = isLogInSuccess(loginResponse)
let isLoggedIn = isLogInSuccess(loginResponse)
if (!loggedIn) {
if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) {
const newLoginForm = await this.getLoginForm(loginResponse)
loginResponse = await this.sendLoginRequest(newLoginForm, loginParams)
}
const currentSession = await this.checkSession()
loggedIn = currentSession.isLoggedIn
const res = await this.checkSession()
isLoggedIn = res.isLoggedIn
if (isLoggedIn) this.userName = res.userName
} else {
this.userName = loginParams.username
}
if (loggedIn) {
if (isLoggedIn) {
if (this.serverType === ServerType.Sas9) {
const casAuthenticationUrl = `${this.serverUrl}/SASStoredProcess/j_spring_cas_security_check`
await this.requestClient.get<string>(
`/SASLogon/login?service=${casAuthenticationUrl}`,
undefined
)
await this.performCASSecurityCheck()
}
this.loginCallback()
}
} else this.userName = ''
return {
isLoggedIn: !!loggedIn,
isLoggedIn,
userName: this.userName
}
}
private async performCASSecurityCheck() {
const casAuthenticationUrl = `${this.serverUrl}/SASStoredProcess/j_spring_cas_security_check`
await this.requestClient.get<string>(
`/SASLogon/login?service=${casAuthenticationUrl}`,
undefined
)
}
private async sendLoginRequest(
loginForm: { [key: string]: any },
loginParams: { [key: string]: any }
@@ -103,14 +180,53 @@ export class AuthManager {
/**
* Checks whether a session is active, or login is required.
* @returns - a promise which resolves with an object containing two values - a boolean `isLoggedIn`, and a string `userName`.
* @returns - a promise which resolves with an object containing three values
* - a boolean `isLoggedIn`
* - a string `userName` and
* - a form `loginForm` if not loggedin.
*/
public async checkSession() {
public async checkSession(): Promise<{
isLoggedIn: boolean
userName: string
loginForm?: any
}> {
const { isLoggedIn, userName } = await this.fetchUserName()
let loginForm = null
if (!isLoggedIn) {
//We will logout to make sure cookies are removed and login form is presented
//Residue can happen in case of session expiration
await this.logOut()
loginForm = await this.getNewLoginForm()
}
return Promise.resolve({
isLoggedIn,
userName: userName.toLowerCase(),
loginForm
})
}
private async getNewLoginForm() {
const { result: formResponse } = await this.requestClient.get<string>(
this.loginUrl.replace('.do', ''),
undefined,
'text/plain'
)
return await this.getLoginForm(formResponse)
}
private async fetchUserName(): Promise<{
isLoggedIn: boolean
userName: string
}> {
//For VIYA we will send request on API endpoint. Which is faster then pinging SASJobExecution.
//For SAS9 we will send request on SASStoredProcess
const url =
this.serverType === 'SASVIYA'
? `${this.serverUrl}/identities`
this.serverType === ServerType.SasViya
? `${this.serverUrl}/identities/users/@currentUser`
: `${this.serverUrl}/SASStoredProcess`
const { result: loginResponse } = await this.requestClient
@@ -120,27 +236,29 @@ export class AuthManager {
})
const isLoggedIn = loginResponse !== 'authErr'
let loginForm = null
const userName = isLoggedIn ? this.extractUserName(loginResponse) : ''
if (!isLoggedIn) {
//We will logout to make sure cookies are removed and login form is presented
//Residue can happen in case of session expiration
await this.logOut()
return { isLoggedIn, userName }
}
const { result: formResponse } = await this.requestClient.get<string>(
this.loginUrl.replace('.do', ''),
undefined,
'text/plain'
)
private extractUserName = (response: any): string => {
switch (this.serverType) {
case ServerType.SasViya:
return response?.id
loginForm = await this.getLoginForm(formResponse)
case ServerType.Sas9:
const matched = response?.match(/"title":"Log Off [0-1a-zA-Z ]*"/)
const username = matched?.[0].slice(17, -1)
if (!username.includes(' ')) return username
return username
.split(' ')
.map((name: string) => name.slice(0, 3).toLowerCase())
.join('')
default:
return ''
}
return Promise.resolve({
isLoggedIn,
userName: this.userName,
loginForm
})
}
private getLoginForm(response: any) {

40
src/auth/openWebPage.ts Normal file
View File

@@ -0,0 +1,40 @@
import { openLoginPrompt } from '../utils/loginPrompt'
interface WindowFeatures {
width: number
height: number
}
const defaultWindowFeatures: WindowFeatures = { width: 500, height: 600 }
export async function openWebPage(
url: string,
windowName: string = '',
WindowFeatures: WindowFeatures = defaultWindowFeatures,
onLoggedOut?: () => Promise<Boolean>
): Promise<Window | null> {
const { width, height } = WindowFeatures
const left = screen.width / 2 - width / 2
const top = screen.height / 2 - height / 2
const loginPopup = window.open(
url,
windowName,
`toolbar=0,location=0,menubar=0,width=${width},height=${height},left=${left},top=${top}`
)
if (!loginPopup) {
const getUserAction: () => Promise<Boolean> = onLoggedOut ?? openLoginPrompt
const doLogin = await getUserAction()
return doLogin
? window.open(
url,
windowName,
`toolbar=0,location=0,menubar=0,width=${width},height=${height},left=${left},top=${top}`
)
: null
}
return loginPopup
}

View File

@@ -3,10 +3,14 @@ import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types'
import axios from 'axios'
import {
mockedCurrentUserApi,
mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
} from './mockResponses'
import { serialize } from '../../utils'
import * as openWebPageModule from '../openWebPage'
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
import * as verifySas9LoginModule from '../verifySas9Login'
import { RequestClient } from '../../request/RequestClient'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -57,134 +61,614 @@ describe('AuthManager', () => {
expect((authManager as any).logoutUrl).toEqual('/SASLogon/logout?')
})
it('should call the auth callback and return when already logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName: 'test',
loginForm: 'test'
})
)
describe('login - default mechanism', () => {
it('should call the auth callback and return when already logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName,
loginForm: 'test'
})
)
const loginResponse = await authManager.logIn(userName, password)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should post a login request to the server if not logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
it('should post a login request to the server when already logged in with other username', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName: 'someOtherUsername',
loginForm: null
})
)
jest
.spyOn(authManager, 'logOut')
.mockImplementation(() => Promise.resolve(true))
jest
.spyOn<any, any>(authManager, 'getNewLoginForm')
.mockImplementation(() =>
Promise.resolve({
name: 'test'
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(authCallback).toHaveBeenCalledTimes(1)
expect(authManager.logOut).toHaveBeenCalledTimes(1)
expect(authManager['getNewLoginForm']).toHaveBeenCalledTimes(1)
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
}
)
expect(authCallback).toHaveBeenCalledTimes(1)
)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should post a login request to the server when not logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: '',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should post a login & a cas_security request to the SAS9 server when not logged in', async () => {
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: '',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: mockLoginSuccessResponse })
)
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
)
const casAuthenticationUrl = `${serverUrl}/SASStoredProcess/j_spring_cas_security_check`
expect(mockedAxios.get).toHaveBeenCalledWith(
`/SASLogon/login?service=${casAuthenticationUrl}`,
getHeadersJson
)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should return empty username if unable to logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: '',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: 'Not Signed in' })
)
const loginResponse = await authManager.logIn(userName, password)
expect(loginResponse.isLoggedIn).toBeFalsy()
expect(loginResponse.userName).toEqual('')
const loginParams = serialize({
_service: 'default',
username: userName,
password,
name: 'test'
})
expect(mockedAxios.post).toHaveBeenCalledWith(
`/SASLogon/login`,
loginParams,
{
withCredentials: true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*'
}
}
)
})
it('should parse and submit the authorisation form when necessary', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn(requestClient, 'authorize')
.mockImplementation(() => Promise.resolve())
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse,
config: { url: 'https://test.com/SASLogon/login' },
request: { responseURL: 'https://test.com/OAuth/authorize' }
})
)
mockedAxios.get.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse
})
)
await authManager.logIn(userName, password)
expect(requestClient.authorize).toHaveBeenCalledWith(
mockLoginAuthoriseRequiredResponse
)
})
})
it('should parse and submit the authorisation form when necessary', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn(requestClient, 'authorize')
.mockImplementation(() => Promise.resolve())
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({
isLoggedIn: false,
userName: 'test',
loginForm: { name: 'test' }
})
)
mockedAxios.post.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse,
config: { url: 'https://test.com/SASLogon/login' },
request: { responseURL: 'https://test.com/OAuth/authorize' }
})
)
describe('login - redirect mechanism', () => {
beforeAll(() => {
jest.mock('../openWebPage')
jest
.spyOn(openWebPageModule, 'openWebPage')
.mockImplementation(() =>
Promise.resolve({ close: jest.fn() } as unknown as Window)
)
jest.mock('../verifySasViyaLogin')
jest
.spyOn(verifySasViyaLoginModule, 'verifySasViyaLogin')
.mockImplementation(() => Promise.resolve({ isLoggedIn: true }))
jest.mock('../verifySas9Login')
jest
.spyOn(verifySas9LoginModule, 'verifySas9Login')
.mockImplementation(() => Promise.resolve({ isLoggedIn: true }))
})
mockedAxios.get.mockImplementationOnce(() =>
Promise.resolve({
data: mockLoginAuthoriseRequiredResponse
})
)
it('should call the auth callback and return when already logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn<any, any>(authManager, 'fetchUserName')
.mockImplementation(() =>
Promise.resolve({
isLoggedIn: true,
userName
})
)
await authManager.logIn(userName, password)
const loginResponse = await authManager.redirectedLogIn({})
expect(requestClient.authorize).toHaveBeenCalledWith(
mockLoginAuthoriseRequiredResponse
)
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should perform login via pop up if not logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn<any, any>(authManager, 'fetchUserName')
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: false,
userName: ''
})
)
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: true,
userName
})
)
const loginResponse = await authManager.redirectedLogIn({})
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`,
'SASLogon',
{
width: 500,
height: 600
},
undefined
)
expect(authManager['fetchUserName']).toHaveBeenCalledTimes(2)
expect(verifySasViyaLoginModule.verifySasViyaLogin).toHaveBeenCalledTimes(
1
)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should perform login via pop up if not logged in with server sas9', async () => {
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn<any, any>(authManager, 'fetchUserName')
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: false,
userName: ''
})
)
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: true,
userName
})
)
const loginResponse = await authManager.redirectedLogIn({})
expect(loginResponse.isLoggedIn).toBeTruthy()
expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`,
'SASLogon',
{
width: 500,
height: 600
},
undefined
)
expect(authManager['fetchUserName']).toHaveBeenCalledTimes(2)
expect(verifySas9LoginModule.verifySas9Login).toHaveBeenCalledTimes(1)
expect(authCallback).toHaveBeenCalledTimes(1)
})
it('should return empty username if user unable to re-login via pop up', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn<any, any>(authManager, 'fetchUserName')
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: false,
userName: ''
})
)
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: true,
userName
})
)
jest
.spyOn(verifySasViyaLoginModule, 'verifySasViyaLogin')
.mockImplementation(() => Promise.resolve({ isLoggedIn: false }))
const loginResponse = await authManager.redirectedLogIn({})
expect(loginResponse.isLoggedIn).toBeFalsy()
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`,
'SASLogon',
{
width: 500,
height: 600
},
undefined
)
expect(authManager['fetchUserName']).toHaveBeenCalledTimes(1)
expect(authCallback).toHaveBeenCalledTimes(0)
})
it('should return empty username if user rejects to re-login', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
jest
.spyOn<any, any>(authManager, 'fetchUserName')
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: false,
userName: ''
})
)
.mockImplementationOnce(() =>
Promise.resolve({
isLoggedIn: true,
userName
})
)
jest
.spyOn(openWebPageModule, 'openWebPage')
.mockImplementation(() => Promise.resolve(null))
const loginResponse = await authManager.redirectedLogIn({})
expect(loginResponse.isLoggedIn).toBeFalsy()
expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`/SASLogon/home`,
'SASLogon',
{
width: 500,
height: 600
},
undefined
)
expect(authManager['fetchUserName']).toHaveBeenCalledTimes(1)
expect(authCallback).toHaveBeenCalledTimes(0)
})
})
it('should check and return session information if logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: '<button onClick="logout">' })
)
describe('checkSession', () => {
it('return session information when logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: mockedCurrentUserApi(userName) })
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(mockedAxios.get).toHaveBeenNthCalledWith(
1,
`http://test-server.com/identities`,
{
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(response.userName).toEqual(userName)
expect(mockedAxios.get).toHaveBeenNthCalledWith(
1,
`http://test-server.com/identities/users/@currentUser`,
{
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
}
}
)
)
})
it('return session information when logged in - SAS9', async () => {
// username cannot have `-` and cannot be uppercased
const username = 'testusername'
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({
data: `"title":"Log Off ${username}","url":"javascript: clearFrame(\"/SASStoredProcess/do?_action=logoff\")"' })`
})
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(response.userName).toEqual(username)
expect(mockedAxios.get).toHaveBeenNthCalledWith(
1,
`http://test-server.com/SASStoredProcess`,
{
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
}
)
})
it('return session information when logged in - SAS9 - having full name in html', async () => {
const fullname = 'FirstName LastName'
const username = 'firlas'
const serverType = ServerType.Sas9
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get.mockImplementation(() =>
Promise.resolve({
data: `"title":"Log Off ${fullname}","url":"javascript: clearFrame(\"/SASStoredProcess/do?_action=logoff\")"' })`
})
)
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeTruthy()
expect(response.userName).toEqual(username)
expect(mockedAxios.get).toHaveBeenNthCalledWith(
1,
`http://test-server.com/SASStoredProcess`,
{
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
}
)
})
it('perform logout when not logged in', async () => {
const authManager = new AuthManager(
serverUrl,
serverType,
requestClient,
authCallback
)
mockedAxios.get
.mockImplementationOnce(() => Promise.resolve({ status: 401 }))
.mockImplementation(() => Promise.resolve({}))
const response = await authManager.checkSession()
expect(response.isLoggedIn).toBeFalsy()
expect(response.userName).toEqual('')
expect(mockedAxios.get).toHaveBeenNthCalledWith(
1,
`http://test-server.com/identities/users/@currentUser`,
{
withCredentials: true,
responseType: 'text',
transformResponse: undefined,
headers: {
Accept: '*/*',
'Content-Type': 'text/plain'
}
}
)
expect(mockedAxios.get).toHaveBeenNthCalledWith(
2,
`/SASLogon/logout.do?`,
getHeadersJson
)
})
})
})
const getHeadersJson = {
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
responseType: 'json'
}

View File

@@ -22,3 +22,28 @@ export const generateToken = (timeToLiveSeconds: number): string => {
const token = `${header}.${payload}.${signature}`
return token
}
export const mockedCurrentUserApi = (username: string) => ({
creationTimeStamp: '2021-04-17T14:13:14.000Z',
modifiedTimeStamp: '2021-08-31T22:08:07.000Z',
id: username,
type: 'user',
name: 'Full User Name',
links: [
{
method: 'GET',
rel: 'self',
href: `/identities/users/${username}`,
uri: `/identities/users/${username}`,
type: 'user'
},
{
method: 'GET',
rel: 'alternate',
href: `/identities/users/${username}`,
uri: `/identities/users/${username}`,
type: 'application/vnd.sas.summary'
}
],
version: 2
})

View File

@@ -0,0 +1,64 @@
/**
* @jest-environment jsdom
*/
import { openWebPage } from '../openWebPage'
import * as loginPromptModule from '../../utils/loginPrompt'
describe('openWebPage', () => {
const serverUrl = 'http://test-server.com'
describe('window.open is not blocked', () => {
const mockedOpen = jest
.fn()
.mockImplementation(() => ({} as unknown as Window))
const originalOpen = window.open
beforeAll(() => {
window.open = mockedOpen
})
afterAll(() => {
window.open = originalOpen
})
it(`should return new Window popup - using default adapter's dialog`, async () => {
await expect(openWebPage(serverUrl)).resolves.toBeDefined()
expect(mockedOpen).toBeCalled()
})
})
describe('window.open is blocked', () => {
const mockedOpen = jest.fn().mockImplementation(() => null)
const originalOpen = window.open
beforeAll(() => {
window.open = mockedOpen
})
afterAll(() => {
window.open = originalOpen
})
it(`should return new Window popup - using default adapter's dialog`, async () => {
jest.mock('../../utils/loginPrompt')
jest
.spyOn(loginPromptModule, 'openLoginPrompt')
.mockImplementation(() => Promise.resolve(true))
await expect(openWebPage(serverUrl)).resolves.toBeDefined()
expect(loginPromptModule.openLoginPrompt).toBeCalled()
expect(mockedOpen).toBeCalled()
})
it(`should return new Window popup - using frontend's provided onloggedOut`, async () => {
const onLoggedOut = jest
.fn()
.mockImplementation(() => Promise.resolve(true))
await expect(
openWebPage(serverUrl, undefined, undefined, onLoggedOut)
).resolves.toBeDefined()
expect(onLoggedOut).toBeCalled()
expect(mockedOpen).toBeCalled()
})
})
})

View File

@@ -0,0 +1,37 @@
/**
* @jest-environment jsdom
*/
import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay'
describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com'
beforeAll(() => {
jest.mock('../../utils')
jest
.spyOn(delayModule, 'delay')
.mockImplementation(() => Promise.resolve({}))
})
it('should return isLoggedIn true by checking state of popup', async () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon/home` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
}
} as unknown as Window
await expect(verifySas9Login(popup)).resolves.toEqual({
isLoggedIn: true
})
})
it('should return isLoggedIn false if user closed popup, already', async () => {
const popup: Window = { closed: true } as unknown as Window
await expect(verifySas9Login(popup)).resolves.toEqual({
isLoggedIn: false
})
})
})

View File

@@ -0,0 +1,38 @@
/**
* @jest-environment jsdom
*/
import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay'
describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com'
beforeAll(() => {
jest.mock('../../utils')
jest
.spyOn(delayModule, 'delay')
.mockImplementation(() => Promise.resolve({}))
document.cookie = encodeURIComponent('Current-User={"userId":"user-hash"}')
})
it('should return isLoggedIn true by checking state of popup', async () => {
const popup = {
window: {
location: { href: serverUrl + `/SASLogon/home` },
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
}
} as unknown as Window
await expect(verifySasViyaLogin(popup)).resolves.toEqual({
isLoggedIn: true
})
})
it('should return isLoggedIn false if user closed popup, already', async () => {
const popup: Window = { closed: true } as unknown as Window
await expect(verifySasViyaLogin(popup)).resolves.toEqual({
isLoggedIn: false
})
})
})

View File

@@ -0,0 +1,20 @@
import { delay } from '../utils'
export async function verifySas9Login(loginPopup: Window): Promise<{
isLoggedIn: boolean
}> {
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn =
loginPopup.window.location.href.includes('SASLogon') &&
loginPopup.window.document.body.innerText.includes('You have signed in.')
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
return { isLoggedIn }
}

View File

@@ -0,0 +1,33 @@
import { delay } from '../utils'
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
isLoggedIn: boolean
}> {
let isLoggedIn = false
let startTime = new Date()
let elapsedSeconds = 0
do {
await delay(1000)
if (loginPopup.closed) break
isLoggedIn = isLoggedInSASVIYA()
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
let isAuthorized = false
startTime = new Date()
do {
await delay(1000)
if (loginPopup.closed) break
isAuthorized =
loginPopup.window.location.href.includes('SASLogon') ||
loginPopup.window.document.body?.innerText?.includes(
'You have signed in.'
)
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isAuthorized && elapsedSeconds < 5 * 60)
return { isLoggedIn: isLoggedIn && isAuthorized }
}
export const isLoggedInSASVIYA = () =>
document.cookie.includes('Current-User') && document.cookie.includes('userId')

View File

@@ -45,7 +45,6 @@ export class ComputeJobExecutor extends BaseJobExecutor {
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
@@ -61,6 +60,8 @@ export class ComputeJobExecutor extends BaseJobExecutor {
}
)
})
await loginCallback()
} else {
reject(new ErrorResponse(e?.message, e))
}

View File

@@ -0,0 +1,143 @@
import {
getValidJson,
parseSasViyaDebugResponse,
parseWeboutResponse
} from '../utils'
import { UploadFile } from '../types/UploadFile'
import {
ErrorResponse,
JobExecutionError,
LoginRequiredError
} from '../types/errors'
import { RequestClient } from '../request/RequestClient'
import { ServerType } from '@sasjs/utils/types'
import { BaseJobExecutor } from './JobExecutor'
interface dataFileUpload {
files: UploadFile[]
params: { [key: string]: any } | null
}
export class FileUploader extends BaseJobExecutor {
constructor(
serverUrl: string,
serverType: ServerType,
private jobsPath: string,
private requestClient: RequestClient
) {
super(serverUrl, serverType)
}
public async execute(
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any
) {
const { files, params }: dataFileUpload = data
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
if (!files?.length)
throw new ErrorResponse('At least one file must be provided.')
if (!sasJob || sasJob === '')
throw new ErrorResponse('sasJob must be provided.')
let paramsString = ''
for (let param in params)
if (params.hasOwnProperty(param))
paramsString += `&${param}=${params[param]}`
const program = config.appLoc
? config.appLoc.replace(/\/?$/, '/') + sasJob.replace(/^\//, '')
: sasJob
const uploadUrl = `${this.jobsPath}/?${
'_program=' + program
}${paramsString}`
const formData = new FormData()
for (let file of files) {
formData.append('file', file.file, file.fileName)
}
const csrfToken = this.requestClient.getCsrfToken('file')
if (csrfToken) formData.append('_csrf', csrfToken.value)
if (config.debug) formData.append('_debug', '131')
if (config.serverType === ServerType.SasViya && config.contextName)
formData.append('_contextname', config.contextName)
const headers = {
'cache-control': 'no-cache',
Accept: '*/*',
'Content-Type': 'text/plain'
}
// currently only web approach is supported for file upload
// therefore log is part of response with debug enabled and must be parsed
const requestPromise = new Promise((resolve, reject) => {
this.requestClient
.post(uploadUrl, formData, undefined, 'application/json', headers)
.then(async (res: any) => {
this.requestClient.appendRequest(res, sasJob, config.debug)
let jsonResponse = res.result
if (config.debug) {
switch (this.serverType) {
case ServerType.SasViya:
jsonResponse = await parseSasViyaDebugResponse(
res.result,
this.requestClient,
config.serverUrl
)
break
case ServerType.Sas9:
jsonResponse =
typeof res.result === 'string'
? parseWeboutResponse(res.result, uploadUrl)
: res.result
break
}
} else {
jsonResponse =
typeof res.result === 'string'
? getValidJson(res.result)
: res.result
}
resolve(jsonResponse)
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
this.requestClient!.appendRequest(e, sasJob, config.debug)
reject(new ErrorResponse(e?.message, e))
}
if (e instanceof LoginRequiredError) {
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
data,
config,
loginRequiredCallback
).then(
(res: any) => {
resolve(res)
},
(err: any) => {
reject(err)
}
)
})
await loginCallback()
} else {
reject(new ErrorResponse('File upload request failed.', e))
}
})
})
return requestPromise
}
}

View File

@@ -7,6 +7,7 @@ import {
} from '../types/errors'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { BaseJobExecutor } from './JobExecutor'
import { appendExtraResponseAttributes } from '../utils'
export class JesJobExecutor extends BaseJobExecutor {
constructor(serverUrl: string, private sasViyaApiClient: SASViyaApiClient) {
@@ -29,21 +30,10 @@ export class JesJobExecutor extends BaseJobExecutor {
.then((response: any) => {
this.sasViyaApiClient.appendRequest(response, sasJob, config.debug)
let responseObject = {}
if (extraResponseAttributes && extraResponseAttributes.length > 0) {
const extraAttributes = extraResponseAttributes.reduce(
(map: any, obj: any) => ((map[obj] = response[obj]), map),
{}
)
responseObject = {
result: response.result,
...extraAttributes
}
} else {
responseObject = response.result
}
const responseObject = appendExtraResponseAttributes(
response,
extraResponseAttributes
)
resolve(responseObject)
})
@@ -55,8 +45,6 @@ export class JesJobExecutor extends BaseJobExecutor {
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
@@ -74,6 +62,8 @@ export class JesJobExecutor extends BaseJobExecutor {
}
)
})
await loginCallback()
} else {
reject(new ErrorResponse(e?.message, e))
}

View File

@@ -1,7 +1,6 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types'
import { SASjsRequest } from '../types'
import { ExtraResponseAttributes } from '@sasjs/utils/types'
import { asyncForEach, parseGeneratedCode, parseSourceCode } from '../utils'
import { asyncForEach } from '../utils'
export type ExecuteFunction = () => Promise<any>
@@ -21,7 +20,6 @@ export abstract class BaseJobExecutor implements JobExecutor {
constructor(protected serverUrl: string, protected serverType: ServerType) {}
private waitingRequests: ExecuteFunction[] = []
private requests: SASjsRequest[] = []
abstract execute(
sasJob: string,

View File

@@ -45,7 +45,7 @@ export class Sas9JobExecutor extends BaseJobExecutor {
if (data) {
try {
formData = generateFileUploadForm(formData, data)
} catch (e) {
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}

View File

@@ -1,9 +1,12 @@
import { ServerType } from '@sasjs/utils/types'
import {
AuthConfig,
ExtraResponseAttributes,
ServerType
} from '@sasjs/utils/types'
import {
ErrorResponse,
JobExecutionError,
LoginRequiredError,
WeboutResponseError
LoginRequiredError
} from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { generateTableUploadForm } from '../file/generateTableUploadForm'
@@ -11,8 +14,8 @@ import { RequestClient } from '../request/RequestClient'
import { SASViyaApiClient } from '../SASViyaApiClient'
import {
isRelativePath,
getValidJson,
parseSasViyaDebugResponse
parseSasViyaDebugResponse,
appendExtraResponseAttributes
} from '../utils'
import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
@@ -37,7 +40,9 @@ export class WebJobExecutor extends BaseJobExecutor {
sasJob: string,
data: any,
config: any,
loginRequiredCallback?: any
loginRequiredCallback?: any,
authConfig?: AuthConfig,
extraResponseAttributes: ExtraResponseAttributes[] = []
) {
const loginCallback = loginRequiredCallback || (() => Promise.resolve())
const program = isRelativePath(sasJob)
@@ -48,10 +53,36 @@ export class WebJobExecutor extends BaseJobExecutor {
let apiUrl = `${config.serverUrl}${this.jobsPath}/?${'_program=' + program}`
if (config.serverType === ServerType.SasViya) {
const jobUri =
config.serverType === ServerType.SasViya
? await this.getJobUri(sasJob)
: ''
let jobUri
try {
jobUri = await this.getJobUri(sasJob)
} catch (e: any) {
return new Promise(async (resolve, reject) => {
if (e instanceof LoginRequiredError) {
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
data,
config,
loginRequiredCallback,
authConfig,
extraResponseAttributes
).then(
(res: any) => {
resolve(res)
},
(err: any) => {
reject(err)
}
)
})
await loginCallback()
} else {
reject(new ErrorResponse(e?.message, e))
}
})
}
apiUrl += jobUri.length > 0 ? '&_job=' + jobUri : ''
@@ -88,7 +119,7 @@ export class WebJobExecutor extends BaseJobExecutor {
// file upload approach
try {
formData = generateFileUploadForm(formData, data)
} catch (e) {
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
} else {
@@ -98,7 +129,7 @@ export class WebJobExecutor extends BaseJobExecutor {
generateTableUploadForm(formData, data)
formData = newFormData
requestParams = { ...requestParams, ...params }
} catch (e) {
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
@@ -114,24 +145,32 @@ export class WebJobExecutor extends BaseJobExecutor {
this.requestClient!.post(apiUrl, formData, undefined)
.then(async (res: any) => {
this.requestClient!.appendRequest(res, sasJob, config.debug)
if (this.serverType === ServerType.SasViya && config.debug) {
const jsonResponse = await parseSasViyaDebugResponse(
res.result,
this.requestClient,
this.serverUrl
)
resolve(jsonResponse)
}
if (this.serverType === ServerType.Sas9 && config.debug) {
let jsonResponse = res.result
if (typeof res.result === 'string')
jsonResponse = parseWeboutResponse(res.result, apiUrl)
getValidJson(jsonResponse)
resolve(res.result)
let jsonResponse = res.result
if (config.debug) {
switch (this.serverType) {
case ServerType.SasViya:
jsonResponse = await parseSasViyaDebugResponse(
res.result,
this.requestClient,
this.serverUrl
)
break
case ServerType.Sas9:
jsonResponse =
typeof res.result === 'string'
? parseWeboutResponse(res.result, apiUrl)
: res.result
break
}
}
getValidJson(res.result as string)
resolve(res.result)
const responseObject = appendExtraResponseAttributes(
{ result: jsonResponse },
extraResponseAttributes
)
resolve(responseObject)
})
.catch(async (e: Error) => {
if (e instanceof JobExecutionError) {
@@ -140,14 +179,14 @@ export class WebJobExecutor extends BaseJobExecutor {
}
if (e instanceof LoginRequiredError) {
await loginCallback()
this.appendWaitingRequest(() => {
return this.execute(
sasJob,
data,
config,
loginRequiredCallback
loginRequiredCallback,
authConfig,
extraResponseAttributes
).then(
(res: any) => {
resolve(res)
@@ -157,6 +196,8 @@ export class WebJobExecutor extends BaseJobExecutor {
}
)
})
await loginCallback()
} else {
reject(new ErrorResponse(e?.message, e))
}

View File

@@ -3,3 +3,4 @@ export * from './JesJobExecutor'
export * from './JobExecutor'
export * from './Sas9JobExecutor'
export * from './WebJobExecutor'
export * from './FileUploader'

View File

@@ -52,25 +52,14 @@ export class RequestClient implements HttpClient {
protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined
protected httpClient: AxiosInstance
protected httpClient!: AxiosInstance
constructor(protected baseUrl: string, allowInsecure = false) {
const https = require('https')
if (allowInsecure && https.Agent) {
this.httpClient = axios.create({
baseURL: baseUrl,
httpsAgent: new https.Agent({
rejectUnauthorized: !allowInsecure
})
})
} else {
this.httpClient = axios.create({
baseURL: baseUrl
})
}
this.createHttpClient(baseUrl, allowInsecure)
}
this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 305
public setConfig(baseUrl: string, allowInsecure = false) {
this.createHttpClient(baseUrl, allowInsecure)
}
public getCsrfToken(type: 'general' | 'file' = 'general') {
@@ -193,7 +182,7 @@ export class RequestClient implements HttpClient {
})
}
public post<T>(
public async post<T>(
url: string,
data: any,
accessToken: string | undefined,
@@ -292,12 +281,16 @@ export class RequestClient implements HttpClient {
}
try {
const response = await this.httpClient.post(url, content, { headers })
const response = await this.httpClient.post(url, content, {
headers,
transformRequest: (requestBody) => requestBody
})
return {
result: response.data,
etag: response.headers['etag'] as string
}
} catch (e) {
} catch (e: any) {
const response = e.response as AxiosResponse
if (response?.status === 403 || response?.status === 449) {
this.parseAndSetFileUploadCsrfToken(response)
@@ -517,6 +510,25 @@ export class RequestClient implements HttpClient {
return responseToReturn
}
private createHttpClient(baseUrl: string, allowInsecure = false) {
const https = require('https')
if (allowInsecure && https.Agent) {
this.httpClient = axios.create({
baseURL: baseUrl,
httpsAgent: new https.Agent({
rejectUnauthorized: !allowInsecure
})
})
} else {
this.httpClient = axios.create({
baseURL: baseUrl
})
}
this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 401
}
}
export const throwIfError = (response: AxiosResponse) => {
@@ -562,46 +574,60 @@ export const throwIfError = (response: AxiosResponse) => {
}
const parseError = (data: string) => {
if (!data) return null
try {
const responseJson = JSON.parse(data?.replace(/[\n\r]/g, ' '))
return responseJson.errorCode && responseJson.message
? new JobExecutionError(
responseJson.errorCode,
responseJson.message,
data?.replace(/[\n\r]/g, ' ')
)
: null
} catch (_) {
try {
const hasError = data?.includes('{"errorCode')
if (hasError) {
const parts = data.split('{"errorCode')
if (parts.length > 1) {
const error = '{"errorCode' + parts[1].split('"}')[0] + '"}'
const errorJson = JSON.parse(error.replace(/[\n\r]/g, ' '))
return new JobExecutionError(
errorJson.errorCode,
errorJson.message,
data?.replace(/[\n\r]/g, '\n')
)
}
return null
}
try {
const hasError = !!data?.match(/stored process not found: /i)
if (hasError) {
const parts = data.split(/stored process not found: /i)
if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(404, message, '')
}
}
} catch (_) {
return null
}
} catch (_) {
return null
if (responseJson.errorCode && responseJson.message) {
return new JobExecutionError(
responseJson.errorCode,
responseJson.message,
data?.replace(/[\n\r]/g, ' ')
)
}
}
} catch (_) {}
try {
const hasError = data?.includes('{"errorCode')
if (hasError) {
const parts = data.split('{"errorCode')
if (parts.length > 1) {
const error = '{"errorCode' + parts[1].split('"}')[0] + '"}'
const errorJson = JSON.parse(error.replace(/[\n\r]/g, ' '))
return new JobExecutionError(
errorJson.errorCode,
errorJson.message,
data?.replace(/[\n\r]/g, '\n')
)
}
}
} catch (_) {}
try {
const hasError = !!data?.match(/stored process not found: /i)
if (hasError) {
const parts = data.split(/stored process not found: /i)
if (parts.length > 1) {
const storedProcessPath = parts[1].split('<i>')[1].split('</i>')[0]
const message = `Stored process not found: ${storedProcessPath}`
return new JobExecutionError(404, message, '')
}
}
} catch (_) {}
try {
const hasError =
!!data?.match(/Stored Process Error/i) &&
!!data?.match(/This request completed with errors./i)
if (hasError) {
const parts = data.split('<h2>SAS Log</h2>')
if (parts.length > 1) {
const log = parts[1].split('<pre>')[1].split('</pre>')[0]
const message = `This request completed with errors.`
return new JobExecutionError(404, message, log)
}
}
} catch (_) {}
return null
}

View File

@@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { FileUploader } from '../FileUploader'
import { FileUploader } from '../job-execution/FileUploader'
import { SASjsConfig, UploadFile } from '../types'
import { RequestClient } from '../request/RequestClient'
import axios from 'axios'
@@ -39,56 +39,66 @@ describe('FileUploader', () => {
}
const fileUploader = new FileUploader(
config,
config.serverUrl,
config.serverType!,
'/jobs/path',
new RequestClient('https://sample.server.com')
)
it('should upload successfully', async () => {
const sasJob = 'test/upload'
const { files, params } = prepareFilesAndParams()
const data = prepareFilesAndParams()
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: sampleResponse })
)
const res = await fileUploader.uploadFile(sasJob, files, params)
const res = await fileUploader.execute(sasJob, data, config)
expect(res).toEqual(JSON.parse(sampleResponse))
})
it('should upload successfully when login is required', async () => {
mockedAxios.post
.mockImplementationOnce(() =>
Promise.resolve({ data: '<form action="Logon">' })
)
.mockImplementationOnce(() => Promise.resolve({ data: sampleResponse }))
const loginCallback = jest.fn().mockImplementation(async () => {
await fileUploader.resendWaitingRequests()
Promise.resolve()
})
const sasJob = 'test'
const data = prepareFilesAndParams()
const res = await fileUploader.execute(sasJob, data, config, loginCallback)
expect(res).toEqual(JSON.parse(sampleResponse))
expect(mockedAxios.post).toHaveBeenCalledTimes(2)
expect(loginCallback).toHaveBeenCalled()
})
it('should an error when no files are provided', async () => {
const sasJob = 'test/upload'
const files: UploadFile[] = []
const params = { table: 'libtable' }
const err = await fileUploader
.uploadFile(sasJob, files, params)
const res: any = await fileUploader
.execute(sasJob, files, params, config)
.catch((err: any) => err)
expect(err.error.message).toEqual('At least one file must be provided.')
expect(res.error.message).toEqual('At least one file must be provided.')
})
it('should throw an error when no sasJob is provided', async () => {
const sasJob = ''
const { files, params } = prepareFilesAndParams()
const data = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
const res: any = await fileUploader
.execute(sasJob, data, config)
.catch((err: any) => err)
expect(err.error.message).toEqual('sasJob must be provided.')
})
it('should throw an error when login is required', async () => {
mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: '<form action="Logon">' })
)
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
.catch((err: any) => err)
expect(err.error.message).toEqual('You must be logged in to upload a file.')
expect(res.error.message).toEqual('sasJob must be provided.')
})
it('should throw an error when invalid JSON is returned by the server', async () => {
@@ -97,12 +107,13 @@ describe('FileUploader', () => {
)
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const data = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
const res: any = await fileUploader
.execute(sasJob, data, config)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.')
expect(res.error.message).toEqual('File upload request failed.')
})
it('should throw an error when the server request fails', async () => {
@@ -111,11 +122,11 @@ describe('FileUploader', () => {
)
const sasJob = 'test'
const { files, params } = prepareFilesAndParams()
const data = prepareFilesAndParams()
const err = await fileUploader
.uploadFile(sasJob, files, params)
const res: any = await fileUploader
.execute(sasJob, data, config)
.catch((err: any) => err)
expect(err.error.message).toEqual('File upload request failed.')
expect(res.error.message).toEqual('File upload request failed.')
})
})

View File

@@ -0,0 +1,5 @@
export interface ExecutionQuery {
_program: string
_debug?: number
_log?: boolean
}

47
src/types/FileTree.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface FileTree {
members: [FolderMember, ServiceMember]
}
export enum MemberType {
folder = 'folder',
service = 'service'
}
export interface FolderMember {
name: string
type: MemberType.folder
members: [FolderMember, ServiceMember]
}
export interface ServiceMember {
name: string
type: MemberType.service
code: string
}
export const isFileTree = (arg: any): arg is FileTree =>
arg &&
arg.members &&
Array.isArray(arg.members) &&
arg.members.filter(
(member: FolderMember | ServiceMember) =>
!isFolderMember(member) && !isServiceMember(member)
).length === 0
const isFolderMember = (arg: any): arg is FolderMember =>
arg &&
typeof arg.name === 'string' &&
arg.type === MemberType.folder &&
arg.members &&
Array.isArray(arg.members) &&
arg.members.filter(
(member: FolderMember | ServiceMember) =>
!isFolderMember(member) && !isServiceMember(member)
).length === 0
const isServiceMember = (arg: any): arg is ServiceMember =>
arg &&
typeof arg.name === 'string' &&
arg.type === MemberType.service &&
arg.code &&
typeof arg.code === 'string'

8
src/types/Login.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface LoginOptions {
onLoggedOut?: () => Promise<boolean>
}
export interface LoginResult {
isLoggedIn: boolean
userName: string
}

View File

@@ -59,4 +59,13 @@ export class SASjsConfig {
* Changing this setting is not recommended.
*/
allowInsecureRequests = false
/**
* Supported login mechanisms are - Redirected and Default
*/
loginMechanism: LoginMechanism = LoginMechanism.Default
}
export enum LoginMechanism {
Default = 'Default',
Redirected = 'Redirected'
}

View File

@@ -12,3 +12,5 @@ export * from './Session'
export * from './UploadFile'
export * from './PollOptions'
export * from './WriteStream'
export * from './FileTree'
export * from './ExecuteScript'

View File

@@ -0,0 +1,22 @@
import { ExtraResponseAttributes } from '@sasjs/utils/types'
export async function appendExtraResponseAttributes(
response: any,
extraResponseAttributes: ExtraResponseAttributes[]
) {
let responseObject = {}
if (extraResponseAttributes?.length) {
const extraAttributes = extraResponseAttributes.reduce(
(map: any, obj: any) => ((map[obj] = response[obj]), map),
{}
)
responseObject = {
result: response.result,
...extraAttributes
}
} else responseObject = response.result
return responseObject
}

2
src/utils/delay.ts Normal file
View File

@@ -0,0 +1,2 @@
export const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -1,6 +1,7 @@
export * from './asyncForEach'
export * from './compareTimestamps'
export * from './convertToCsv'
export * from './delay'
export * from './isNode'
export * from './isRelativePath'
export * from './isUri'
@@ -15,3 +16,4 @@ export * from './parseWeboutResponse'
export * from './fetchLogByChunks'
export * from './getValidJson'
export * from './parseViyaDebugResponse'
export * from './appendExtraResponseAttributes'

View File

@@ -0,0 +1,177 @@
enum domIDs {
styles = 'sasjsAdapterStyles',
overlay = 'sasjsAdapterLoginPromptBG',
dialog = 'sasjsAdapterLoginPrompt'
}
const cssPrefix = 'sasjs-adapter'
const classes = {
popUp: `${cssPrefix}popUp`,
popUpBG: `${cssPrefix}popUpBG`
}
export const openLoginPrompt = (): Promise<boolean> => {
return new Promise(async (resolve) => {
const style = document.createElement('style')
style.id = domIDs.styles
style.innerText = cssContent
const loginPromptBG = document.createElement('div')
loginPromptBG.id = domIDs.overlay
loginPromptBG.classList.add(classes.popUpBG)
const loginPrompt = document.createElement('div')
loginPrompt.id = domIDs.dialog
loginPrompt.classList.add(classes.popUp)
const title = document.createElement('h1')
title.innerText = 'Session Expired!'
loginPrompt.appendChild(title)
const descHolder = document.createElement('div')
const desc = document.createElement('span')
desc.innerText = 'You need to relogin, click OK to login.'
descHolder.appendChild(desc)
loginPrompt.appendChild(descHolder)
const buttonCancel = document.createElement('button')
buttonCancel.classList.add('cancel')
buttonCancel.innerText = 'Cancel'
buttonCancel.onclick = () => {
closeLoginPrompt()
resolve(false)
}
loginPrompt.appendChild(buttonCancel)
const buttonOk = document.createElement('button')
buttonOk.classList.add('confirm')
buttonOk.innerText = 'Ok'
buttonOk.onclick = () => {
closeLoginPrompt()
resolve(true)
}
loginPrompt.appendChild(buttonOk)
document.body.style.overflow = 'hidden'
document.body.appendChild(style)
document.body.appendChild(loginPromptBG)
document.body.appendChild(loginPrompt)
})
}
const closeLoginPrompt = () => {
Object.values(domIDs).forEach((id) => {
const elem = document.getElementById(id)
elem?.parentNode?.removeChild(elem)
})
document.body.style.overflow = 'auto'
}
const cssContent = `
.${classes.popUpBG} ,
.${classes.popUp} {
z-index: 10000;
}
.${classes.popUp} {
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
display: block;
position: fixed;
top: 40%;
left: 50%;
padding: 0;
font-size: 14px;
font-family: 'PT Sans', sans-serif;
color: #fff;
border-style: none;
z-index: 999;
overflow: hidden;
background: rgba(0, 0, 0, 0.2);
margin: 0;
width: 100%;
max-width: 300px;
height: auto;
max-height: 300px;
transform: translate(-50%, -50%);
}
.${classes.popUp} > h1 {
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
padding: 5px;
min-height: 40px;
font-size: 1.2em;
font-weight: bold;
text-align: center;
color: #fff;
background-color: transparent;
border-style: none;
border-width: 5px;
border-color: black;
}
.${classes.popUp} > div {
width: 100%;
height: calc(100% -108px);
margin: 0;
display: block;
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
padding: 5%;
text-align: center;
border-width: 1px;
border-color: #ccc;
border-style: none none solid none;
overflow: auto;
}
.${classes.popUp} > div > span {
display: table-cell;
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
margin: 0;
padding: 0;
width: 300px;
height: 108px;
vertical-align: middle;
border-style: none;
}
.${classes.popUp} .cancel {
float: left;
}
.${classes.popUp} .confirm {
float: right;
}
.${classes.popUp} > button {
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
margin: 0;
padding: 10px;
width: 50%;
border: 1px none #ccc;
color: #fff;
font-family: inherit;
cursor: pointer;
height: 50px;
background: rgba(1, 1, 1, 0.2);
}
.${classes.popUp} > button:hover {
background: rgba(0, 0, 0, 0.2);
}
.${classes.popUpBG} {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
opacity: 0.95;
z-index: 50;
background-image: radial-gradient(#0378cd, #012036);
}
`

View File

@@ -25,6 +25,6 @@ export const parseSasViyaDebugResponse = async (
}
return requestClient
.get(serverUrl + jsonUrl, undefined)
.get(serverUrl + jsonUrl, undefined, 'text/plain')
.then((res: any) => getValidJson(res.result))
}