mirror of
https://github.com/sasjs/adapter.git
synced 2025-12-15 18:54:36 +00:00
Compare commits
136 Commits
v2.8.14
...
redirected
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66061c6471 | ||
|
|
a181914c36 | ||
|
|
539405e249 | ||
|
|
d9c27efa8d | ||
|
|
4623b9665b | ||
|
|
3ae0809ee5 | ||
|
|
0ea6e839ac | ||
|
|
a00bf5ba67 | ||
|
|
e0b09adbba | ||
|
|
19a57dbf6e | ||
|
|
cd2b32f2f4 | ||
|
|
a1f5355d6a | ||
|
|
0972c0deaa | ||
|
|
e1a5cc9e45 | ||
|
|
351a22cb3c | ||
|
|
3ccd35a4e2 | ||
|
|
e4956cc1d4 | ||
|
|
291ba51b07 | ||
|
|
ed72c5c48c | ||
|
|
ad4eead4ca | ||
|
|
f40a86f0f6 | ||
|
|
aa9383a483 | ||
|
|
867422f4cc | ||
|
|
2a6e29b5b8 | ||
|
|
ba105f609c | ||
|
|
e4d669f9b6 | ||
|
|
5edf09e0a7 | ||
|
|
5a695f495c | ||
|
|
f231edb4a6 | ||
|
|
389ef94cd5 | ||
|
|
4c90f66dbc | ||
| ab8643a89a | |||
|
|
b831b93133 | ||
|
|
0c3aab673a | ||
|
|
83353326fb | ||
|
|
db7a5d601e | ||
|
|
ee977f4fab | ||
| 33e7564e8f | |||
|
|
1a59f95be7 | ||
| 77c4c473c1 | |||
| 47ff1a2293 | |||
|
|
97918f301b | ||
|
|
830a907bd1 | ||
|
|
ffae344476 | ||
|
|
4f62cd0148 | ||
| bd92c1925e | |||
|
|
6c29d7823b | ||
| 3c9f133374 | |||
| e72195ca5d | |||
| 3e7ddf59b4 | |||
| cd67fb38dc | |||
| 78149e6c54 | |||
| 63e220c5be | |||
| 8464e506e0 | |||
| 0bc69401e5 | |||
| 47fe7686cb | |||
|
|
dd2b3671fd | ||
| bd03b2b06d | |||
|
|
2b2b8e6429 | ||
|
|
5375d0a208 | ||
|
|
f2da84829e | ||
|
|
fc1c93957c | ||
|
|
f172ad66bc | ||
|
|
046c58bb80 | ||
|
|
bf825a4f65 | ||
|
|
d58cff9081 | ||
|
|
7ab1964746 | ||
|
|
b118280a77 | ||
|
|
5317c14d54 | ||
|
|
85fed5cd76 | ||
|
|
6f9196c690 | ||
|
|
2d0a73e74d | ||
|
|
ac8821baec | ||
|
|
0b9284e481 | ||
|
|
7b7a80c502 | ||
|
|
1ace15a308 | ||
|
|
e1b3ef7c8c | ||
| 710056bded | |||
|
|
fb7a0f43e1 | ||
|
|
6c901f1c21 | ||
| 26f008d527 | |||
| 56ebc7be3b | |||
|
|
0ea66f6d37 | ||
|
|
cb30ed2b98 | ||
|
|
dfbe2d8f94 | ||
|
|
eac9da22bf | ||
|
|
626fc2e15f | ||
|
|
87e2edbd6c | ||
|
|
7cf681bea3 | ||
|
|
281a145bef | ||
|
|
15d5f9ec91 | ||
|
|
0a6c5a0ec4 | ||
|
|
2a9526d056 | ||
|
|
c2ff28c323 | ||
|
|
fbaa2327c6 | ||
|
|
50710ee1df | ||
|
|
062ba91c17 | ||
| 6dd1d47bb2 | |||
| e70a9645ef | |||
| aeabc29e55 | |||
|
|
9600fa2512 | ||
|
|
7951817480 | ||
|
|
405eea1d6c | ||
|
|
e3f189eed4 | ||
|
|
0bb42c5e3c | ||
|
|
c02eac196e | ||
|
|
3fb0d863e9 | ||
|
|
6d573d3897 | ||
|
|
33280d7a5b | ||
|
|
507722da0d | ||
|
|
c8e029cff4 | ||
|
|
7bd2e31f3b | ||
|
|
cfa0c8b9af | ||
|
|
df9c1c643f | ||
|
|
5c8d311ae8 | ||
| e1a76bc45a | |||
| 85e5ade93a | |||
| 4a61fb8f7f | |||
| 5347aeba09 | |||
|
|
7ac7c5e52b | ||
| 5098342dfe | |||
| c69be8ffc3 | |||
| 69999d8e8b | |||
|
|
1594f0c7db | ||
|
|
1ff3937d11 | ||
|
|
d4725d2e54 | ||
|
|
b9f368193d | ||
|
|
4257ec78aa | ||
|
|
a0fbe1a740 | ||
|
|
123b9fb535 | ||
|
|
f57c7b8f7d | ||
|
|
1c90f4f455 | ||
|
|
0114a80e38 | ||
|
|
13be2f9c70 | ||
|
|
e396091aa7 | ||
|
|
04ccbf6843 |
103
.all-contributorsrc
Normal file
103
.all-contributorsrc
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"projectName": "adapter",
|
||||
"projectOwner": "sasjs",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"commitConvention": "angular",
|
||||
"contributors": [
|
||||
{
|
||||
"login": "krishna-acondy",
|
||||
"name": "Krishna Acondy",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/2980428?v=4",
|
||||
"profile": "https://krishna-acondy.io/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"infra",
|
||||
"blog",
|
||||
"content",
|
||||
"ideas",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "YuryShkoda",
|
||||
"name": "Yury Shkoda",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4",
|
||||
"profile": "https://www.erudicat.com/",
|
||||
"contributions": [
|
||||
"code",
|
||||
"infra",
|
||||
"ideas",
|
||||
"test",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "medjedovicm",
|
||||
"name": "Mihajlo Medjedovic",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4",
|
||||
"profile": "https://github.com/medjedovicm",
|
||||
"contributions": [
|
||||
"code",
|
||||
"infra",
|
||||
"test",
|
||||
"review"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "allanbowe",
|
||||
"name": "Allan Bowe",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4",
|
||||
"profile": "https://github.com/allanbowe",
|
||||
"contributions": [
|
||||
"code",
|
||||
"review",
|
||||
"test",
|
||||
"mentoring",
|
||||
"maintenance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "saadjutt01",
|
||||
"name": "Muhammad Saad ",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4",
|
||||
"profile": "https://github.com/saadjutt01",
|
||||
"contributions": [
|
||||
"code",
|
||||
"review",
|
||||
"test",
|
||||
"mentoring",
|
||||
"infra"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "sabhas",
|
||||
"name": "Sabir Hassan",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4",
|
||||
"profile": "https://github.com/sabhas",
|
||||
"contributions": [
|
||||
"code",
|
||||
"review",
|
||||
"test",
|
||||
"ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "VladislavParhomchik",
|
||||
"name": "VladislavParhomchik",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4",
|
||||
"profile": "https://github.com/VladislavParhomchik",
|
||||
"contributions": [
|
||||
"test",
|
||||
"review"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"skipCi": true
|
||||
}
|
||||
@@ -6,7 +6,7 @@ GREEN="\033[1;32m"
|
||||
# temporary file which holds the message).
|
||||
commit_message=$(cat "$1")
|
||||
|
||||
if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 \-\*]+\))?!?: .+$") then
|
||||
if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 -\*]+\))?!?: .+$") then
|
||||
echo "${GREEN} ✔ Commit message meets Conventional Commit standards"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
node-version: [15.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
run: npm run lint
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
- name: Generate coverage report
|
||||
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Package
|
||||
run: npm run package:lib
|
||||
env:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
sasjs-tests/
|
||||
docs/
|
||||
.github/
|
||||
CONTRIBUTING.md
|
||||
*.md
|
||||
*.spec.ts
|
||||
|
||||
@@ -12,9 +12,9 @@ What code changes have been made to achieve the intent.
|
||||
|
||||
## Checks
|
||||
|
||||
No PR (that involves a non-trivial code change) should be merged, unless all four of the items below are confirmed! If an urgent fix is needed - use a tar file.
|
||||
No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file.
|
||||
|
||||
|
||||
- [ ] Code is formatted correctly (`npm run lint:fix`).
|
||||
- [ ] All unit tests are passing (`npm test`).
|
||||
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
|
||||
- [ ] All `sasjs-tests` are passing (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
|
||||
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya
|
||||
|
||||
31
README.md
31
README.md
@@ -172,7 +172,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.
|
||||
* `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.
|
||||
* `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`.
|
||||
|
||||
The adapter supports a number of approaches for interfacing with Viya (`serverType` is `SASVIYA`). For maximum performance, be sure to [configure your compute context](https://sasjs.io/guide-viya/#shared-account-and-server-re-use) with `reuseServerProcesses` as `true` and a system account in `runServerAs`. This functionality is available since Viya 3.5. This configuration is supported when [creating contexts using the CLI](https://sasjs.io/sasjs-cli-context/#sasjs-context-create).
|
||||
@@ -234,3 +234,32 @@ If you are a SAS 9 or SAS Viya customer you can also request a copy of [Data Con
|
||||
If you find this library useful, help us grow our star graph!
|
||||
|
||||

|
||||
|
||||
## Contributors ✨
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://krishna-acondy.io/"><img src="https://avatars.githubusercontent.com/u/2980428?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krishna Acondy</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=krishna-acondy" title="Code">💻</a> <a href="#infra-krishna-acondy" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#blog-krishna-acondy" title="Blogposts">📝</a> <a href="#content-krishna-acondy" title="Content">🖋</a> <a href="#ideas-krishna-acondy" title="Ideas, Planning, & Feedback">🤔</a> <a href="#video-krishna-acondy" title="Videos">📹</a></td>
|
||||
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Code">💻</a> <a href="#infra-YuryShkoda" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-YuryShkoda" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Tests">⚠️</a> <a href="#video-YuryShkoda" title="Videos">📹</a></td>
|
||||
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a> <a href="#infra-medjedovicm" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Amedjedovicm" title="Reviewed Pull Requests">👀</a></td>
|
||||
<td align="center"><a href="https://github.com/allanbowe"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Aallanbowe" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Tests">⚠️</a> <a href="#mentoring-allanbowe" title="Mentoring">🧑🏫</a> <a href="#maintenance-allanbowe" title="Maintenance">🚧</a></td>
|
||||
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Saad </b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asaadjutt01" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Tests">⚠️</a> <a href="#mentoring-saadjutt01" title="Mentoring">🧑🏫</a> <a href="#infra-saadjutt01" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asabhas" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Tests">⚠️</a> <a href="#ideas-sabhas" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>VladislavParhomchik</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=VladislavParhomchik" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3AVladislavParhomchik" title="Reviewed Pull Requests">👀</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
16
checkNodeVersion.js
Normal file
16
checkNodeVersion.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const result = process.versions
|
||||
if (result && result.node) {
|
||||
if (parseInt(result.node) < 14) {
|
||||
console.log(
|
||||
'\x1b[31m%s\x1b[0m',
|
||||
`❌ Process failed due to Node Version,\nPlease install and use Node Version >= 14\nYour current Node Version is: ${result.node}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'\x1b[31m%s\x1b[0m',
|
||||
'Something went wrong while checking Node version'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
3634
package-lock.json
generated
3634
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -3,8 +3,10 @@
|
||||
"description": "JavaScript adapter for SAS",
|
||||
"homepage": "https://adapter.sasjs.io",
|
||||
"scripts": {
|
||||
"preinstall": "node checkNodeVersion",
|
||||
"prebuild": "node checkNodeVersion",
|
||||
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node",
|
||||
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
|
||||
"package:lib": "npm run build && copyfiles ./package.json ./checkNodeVersion.js build && cd build && npm version \"5.0.0\" && npm pack",
|
||||
"publish:lib": "npm run build && cd build && npm publish",
|
||||
"lint:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
||||
"lint": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --check \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
||||
@@ -38,35 +40,36 @@
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/mime": "^2.0.3",
|
||||
"@types/tough-cookie": "^4.0.0",
|
||||
"@types/tough-cookie": "^4.0.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cp": "^0.2.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"jest": "^27.0.6",
|
||||
"jest": "^27.1.0",
|
||||
"jest-extended": "^0.11.5",
|
||||
"mime": "^2.5.2",
|
||||
"node-polyfill-webpack-plugin": "^1.1.4",
|
||||
"path": "^0.12.7",
|
||||
"process": "^0.11.10",
|
||||
"rimraf": "^3.0.2",
|
||||
"semantic-release": "^17.4.4",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"semantic-release": "^17.4.7",
|
||||
"terser-webpack-plugin": "^5.2.0",
|
||||
"ts-jest": "^27.0.3",
|
||||
"ts-loader": "^9.2.2",
|
||||
"tslint": "^6.1.3",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typedoc": "^0.21.2",
|
||||
"typedoc": "^0.21.9",
|
||||
"typedoc-neo-theme": "^1.1.1",
|
||||
"typedoc-plugin-external-module-name": "^4.0.6",
|
||||
"typescript": "^4.3.4",
|
||||
"webpack": "^5.41.1",
|
||||
"typescript": "4.3.5",
|
||||
"webpack": "^5.44.0",
|
||||
"webpack-cli": "^4.7.2"
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "^2.23.2",
|
||||
"@sasjs/utils": "^2.30.0",
|
||||
"axios": "^0.21.1",
|
||||
"axios-cookiejar-support": "^1.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
|
||||
22129
sasjs-tests/package-lock.json
generated
22129
sasjs-tests/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isUrl } from './utils'
|
||||
import { isUrl, getValidJson, parseSasViyaDebugResponse } from './utils'
|
||||
import { UploadFile } from './types/UploadFile'
|
||||
import { ErrorResponse, LoginRequiredError } from './types/errors'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
@@ -61,15 +61,27 @@ export class FileUploader {
|
||||
'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((res) => {
|
||||
let result
|
||||
.then(async (res) => {
|
||||
if (
|
||||
this.sasjsConfig.serverType === ServerType.SasViya &&
|
||||
this.sasjsConfig.debug
|
||||
) {
|
||||
const jsonResponse = await parseSasViyaDebugResponse(
|
||||
res.result as string,
|
||||
this.requestClient,
|
||||
this.sasjsConfig.serverUrl
|
||||
)
|
||||
return jsonResponse
|
||||
}
|
||||
|
||||
result =
|
||||
typeof res.result === 'string' ? JSON.parse(res.result) : res.result
|
||||
return typeof res.result === 'string'
|
||||
? getValidJson(res.result)
|
||||
: res.result
|
||||
|
||||
return result
|
||||
//TODO: append to SASjs requests
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
|
||||
@@ -10,9 +10,13 @@ import { isUrl } from './utils'
|
||||
export class SAS9ApiClient {
|
||||
private requestClient: Sas9RequestClient
|
||||
|
||||
constructor(private serverUrl: string, private jobsPath: string) {
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private jobsPath: string,
|
||||
allowInsecureRequests: boolean
|
||||
) {
|
||||
if (serverUrl) isUrl(serverUrl)
|
||||
this.requestClient = new Sas9RequestClient(serverUrl, false)
|
||||
this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
51
src/SASViyaApiClient.spec.ts
Normal file
51
src/SASViyaApiClient.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
import { SASViyaApiClient } from './SASViyaApiClient'
|
||||
import { Folder } from './types'
|
||||
import { RootFolderNotFoundError } from './types/errors'
|
||||
|
||||
const mockFolder: Folder = {
|
||||
id: '1',
|
||||
uri: '/folder',
|
||||
links: [],
|
||||
memberCount: 1
|
||||
}
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
const sasViyaApiClient = new SASViyaApiClient(
|
||||
'https://test.com',
|
||||
'/test',
|
||||
'test context',
|
||||
requestClient
|
||||
)
|
||||
|
||||
describe('SASViyaApiClient', () => {
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
it('should throw an error when the root folder is not found on the server', async () => {
|
||||
jest
|
||||
.spyOn(requestClient, 'get')
|
||||
.mockImplementation(() => Promise.reject('Not Found'))
|
||||
const error = await sasViyaApiClient
|
||||
.createFolder('test', '/foo')
|
||||
.catch((e) => e)
|
||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest
|
||||
.spyOn(requestClient, 'get')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: mockFolder, etag: '', status: 200 })
|
||||
)
|
||||
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: mockFolder, etag: '', status: 200 })
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
convertToCSV,
|
||||
isRelativePath,
|
||||
isUri,
|
||||
isUrl,
|
||||
fetchLogByChunks
|
||||
} from './utils'
|
||||
import { isRelativePath, isUri, isUrl } from './utils'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import {
|
||||
Job,
|
||||
@@ -17,25 +11,19 @@ import {
|
||||
JobDefinition,
|
||||
PollOptions
|
||||
} from './types'
|
||||
import {
|
||||
ComputeJobExecutionError,
|
||||
JobExecutionError,
|
||||
NotFoundError
|
||||
} from './types/errors'
|
||||
import { formatDataForRequest } from './utils/formatDataForRequest'
|
||||
import { JobExecutionError, RootFolderNotFoundError } from './types/errors'
|
||||
import { SessionManager } from './SessionManager'
|
||||
import { ContextManager } from './ContextManager'
|
||||
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
|
||||
import {
|
||||
isAccessTokenExpiring,
|
||||
isRefreshTokenExpiring
|
||||
} from '@sasjs/utils/auth'
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types'
|
||||
import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as mime from 'mime'
|
||||
import { pollJobState } from './api/viya/pollJobState'
|
||||
import { getTokens } from './auth/getTokens'
|
||||
import { uploadTables } from './api/viya/uploadTables'
|
||||
import { executeScript } from './api/viya/executeScript'
|
||||
import { getAccessToken } from './auth/getAccessToken'
|
||||
import { refreshTokens } from './auth/refreshTokens'
|
||||
|
||||
/**
|
||||
* A client for interfacing with the SAS Viya REST API.
|
||||
@@ -171,13 +159,6 @@ export class SASViyaApiClient {
|
||||
throw new Error(`Execution context ${contextName} not found.`)
|
||||
}
|
||||
|
||||
const createSessionRequest = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
const { result: createdSession } = await this.requestClient.post<Session>(
|
||||
`/compute/contexts/${executionContext.id}/sessions`,
|
||||
{},
|
||||
@@ -292,249 +273,22 @@ export class SASViyaApiClient {
|
||||
printPid = false,
|
||||
variables?: MacroVar
|
||||
): Promise<any> {
|
||||
let access_token = (authConfig || {}).access_token
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
}
|
||||
|
||||
const logger = process.logger || console
|
||||
|
||||
try {
|
||||
let executionSessionId: string
|
||||
|
||||
const session = await this.sessionManager
|
||||
.getSession(access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session. ')
|
||||
})
|
||||
|
||||
executionSessionId = session!.id
|
||||
|
||||
if (printPid) {
|
||||
const { result: jobIdVariable } = await this.sessionManager
|
||||
.getVariable(executionSessionId, 'SYSJOBID', access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session variable. ')
|
||||
})
|
||||
|
||||
if (jobIdVariable && jobIdVariable.value) {
|
||||
const relativeJobPath = this.rootFolderName
|
||||
? jobPath.split(this.rootFolderName).join('').replace(/^\//, '')
|
||||
: jobPath
|
||||
|
||||
const logger = new Logger(debug ? LogLevel.Debug : LogLevel.Info)
|
||||
|
||||
logger.info(
|
||||
`Triggered '${relativeJobPath}' with PID ${
|
||||
jobIdVariable.value
|
||||
} at ${timestampToYYYYMMDDHHMMSS()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const jobArguments: { [key: string]: any } = {
|
||||
_contextName: contextName,
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: true,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: true
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
jobArguments['_OMITTEXTLOG'] = false
|
||||
jobArguments['_OMITSESSIONRESULTS'] = false
|
||||
}
|
||||
|
||||
let fileName
|
||||
|
||||
if (isRelativePath(jobPath)) {
|
||||
fileName = `exec-${
|
||||
jobPath.includes('/') ? jobPath.split('/')[1] : jobPath
|
||||
}`
|
||||
} else {
|
||||
const jobPathParts = jobPath.split('/')
|
||||
fileName = jobPathParts.pop()
|
||||
}
|
||||
|
||||
let jobVariables: any = {
|
||||
SYS_JES_JOB_URI: '',
|
||||
_program: isRelativePath(jobPath)
|
||||
? this.rootFolderName + '/' + jobPath
|
||||
: jobPath
|
||||
}
|
||||
|
||||
if (variables) jobVariables = { ...jobVariables, ...variables }
|
||||
|
||||
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
||||
|
||||
let files: any[] = []
|
||||
|
||||
if (data) {
|
||||
if (JSON.stringify(data).includes(';')) {
|
||||
files = await this.uploadTables(data, access_token).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while uploading tables. ')
|
||||
})
|
||||
|
||||
jobVariables['_webin_file_count'] = files.length
|
||||
|
||||
files.forEach((fileInfo, index) => {
|
||||
jobVariables[
|
||||
`_webin_fileuri${index + 1}`
|
||||
] = `/files/files/${fileInfo.file.id}`
|
||||
jobVariables[`_webin_name${index + 1}`] = fileInfo.tableName
|
||||
})
|
||||
} else {
|
||||
jobVariables = { ...jobVariables, ...formatDataForRequest(data) }
|
||||
}
|
||||
}
|
||||
|
||||
// Execute job in session
|
||||
const jobRequestBody = {
|
||||
name: fileName,
|
||||
description: 'Powered by SASjs',
|
||||
code: linesOfCode,
|
||||
variables: jobVariables,
|
||||
arguments: jobArguments
|
||||
}
|
||||
|
||||
const { result: postedJob, etag } = await this.requestClient
|
||||
.post<Job>(
|
||||
`/compute/sessions/${executionSessionId}/jobs`,
|
||||
jobRequestBody,
|
||||
access_token
|
||||
)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while posting job. ')
|
||||
})
|
||||
|
||||
if (!waitForResult) return session
|
||||
|
||||
if (debug) {
|
||||
logger.info(`Job has been submitted for '${fileName}'.`)
|
||||
logger.info(
|
||||
`You can monitor the job progress at '${this.serverUrl}${
|
||||
postedJob.links.find((l: any) => l.rel === 'state')!.href
|
||||
}'.`
|
||||
)
|
||||
}
|
||||
|
||||
const jobStatus = await this.pollJobState(
|
||||
postedJob,
|
||||
etag,
|
||||
authConfig,
|
||||
pollOptions
|
||||
).catch(async (err) => {
|
||||
const error = err?.response?.data
|
||||
const result = /err=[0-9]*,/.exec(error)
|
||||
|
||||
const errorCode = '5113'
|
||||
if (result?.[0]?.slice(4, -1) === errorCode) {
|
||||
const sessionLogUrl =
|
||||
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
||||
const logCount = 1000000
|
||||
err.log = await fetchLogByChunks(
|
||||
this.requestClient,
|
||||
access_token!,
|
||||
sessionLogUrl,
|
||||
logCount
|
||||
)
|
||||
}
|
||||
throw prefixMessage(err, 'Error while polling job status. ')
|
||||
})
|
||||
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
}
|
||||
|
||||
const { result: currentJob } = await this.requestClient
|
||||
.get<Job>(
|
||||
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
|
||||
access_token
|
||||
)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting job. ')
|
||||
})
|
||||
|
||||
let jobResult
|
||||
let log = ''
|
||||
|
||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||
|
||||
if (debug && logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
log = await fetchLogByChunks(
|
||||
this.requestClient,
|
||||
access_token!,
|
||||
logUrl,
|
||||
logCount
|
||||
)
|
||||
}
|
||||
|
||||
if (jobStatus === 'failed' || jobStatus === 'error') {
|
||||
return Promise.reject(new ComputeJobExecutionError(currentJob, log))
|
||||
}
|
||||
|
||||
let resultLink
|
||||
|
||||
if (expectWebout) {
|
||||
resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
||||
} else {
|
||||
return { job: currentJob, log }
|
||||
}
|
||||
|
||||
if (resultLink) {
|
||||
jobResult = await this.requestClient
|
||||
.get<any>(resultLink, access_token, 'text/plain')
|
||||
.catch(async (e) => {
|
||||
if (e instanceof NotFoundError) {
|
||||
if (logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
log = await fetchLogByChunks(
|
||||
this.requestClient,
|
||||
access_token!,
|
||||
logUrl,
|
||||
logCount
|
||||
)
|
||||
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
log
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: JSON.stringify(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this.sessionManager
|
||||
.clearSession(executionSessionId, access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while clearing session. ')
|
||||
})
|
||||
|
||||
return { result: jobResult?.result, log }
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
return this.executeScript(
|
||||
jobPath,
|
||||
linesOfCode,
|
||||
contextName,
|
||||
authConfig,
|
||||
data,
|
||||
debug,
|
||||
false,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
throw prefixMessage(e, 'Error while executing script. ')
|
||||
}
|
||||
}
|
||||
return executeScript(
|
||||
this.requestClient,
|
||||
this.sessionManager,
|
||||
this.rootFolderName,
|
||||
jobPath,
|
||||
linesOfCode,
|
||||
contextName,
|
||||
authConfig,
|
||||
data,
|
||||
debug,
|
||||
expectWebout,
|
||||
waitForResult,
|
||||
pollOptions,
|
||||
printPid,
|
||||
variables
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,9 +335,6 @@ export class SASViyaApiClient {
|
||||
const formData = new NodeFormData()
|
||||
formData.append('file', contentBuffer, fileName)
|
||||
|
||||
const mimeType =
|
||||
mime.getType(fileName.match(/\.[0-9a-z]+$/i)?.[0] || '') ?? 'text/plain'
|
||||
|
||||
return (
|
||||
await this.requestClient.post<File>(
|
||||
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=file#rawUpload`,
|
||||
@@ -630,7 +381,11 @@ export class SASViyaApiClient {
|
||||
)
|
||||
const newFolderName = `${parentFolderPath.split('/').pop()}`
|
||||
if (newParentFolderPath === '') {
|
||||
throw new Error('Root folder has to be present on the server.')
|
||||
throw new RootFolderNotFoundError(
|
||||
parentFolderPath,
|
||||
this.serverUrl,
|
||||
accessToken
|
||||
)
|
||||
}
|
||||
logger.info(
|
||||
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
|
||||
@@ -769,37 +524,7 @@ export class SASViyaApiClient {
|
||||
clientSecret: string,
|
||||
authCode: string
|
||||
): Promise<SasAuthResponse> {
|
||||
const url = this.serverUrl + '/SASLogon/oauth/token'
|
||||
let token
|
||||
if (typeof Buffer === 'undefined') {
|
||||
token = btoa(clientId + ':' + clientSecret)
|
||||
} else {
|
||||
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
}
|
||||
const headers = {
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
|
||||
let formData
|
||||
if (typeof FormData === 'undefined') {
|
||||
formData = new NodeFormData()
|
||||
} else {
|
||||
formData = new FormData()
|
||||
}
|
||||
formData.append('grant_type', 'authorization_code')
|
||||
formData.append('code', authCode)
|
||||
|
||||
const authResponse = await this.requestClient
|
||||
.post(
|
||||
url,
|
||||
formData,
|
||||
undefined,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
.then((res) => res.result as SasAuthResponse)
|
||||
|
||||
return authResponse
|
||||
return getAccessToken(this.requestClient, clientId, clientSecret, authCode)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -813,39 +538,12 @@ export class SASViyaApiClient {
|
||||
clientSecret: string,
|
||||
refreshToken: string
|
||||
) {
|
||||
const url = this.serverUrl + '/SASLogon/oauth/token'
|
||||
let token
|
||||
if (typeof Buffer === 'undefined') {
|
||||
token = btoa(clientId + ':' + clientSecret)
|
||||
} else {
|
||||
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
}
|
||||
const headers = {
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
|
||||
let formData
|
||||
if (typeof FormData === 'undefined') {
|
||||
formData = new NodeFormData()
|
||||
formData.append('grant_type', 'refresh_token')
|
||||
formData.append('refresh_token', refreshToken)
|
||||
} else {
|
||||
formData = new FormData()
|
||||
formData.append('grant_type', 'refresh_token')
|
||||
formData.append('refresh_token', refreshToken)
|
||||
}
|
||||
|
||||
const authResponse = await this.requestClient
|
||||
.post<SasAuthResponse>(
|
||||
url,
|
||||
formData,
|
||||
undefined,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
.then((res) => res.result)
|
||||
|
||||
return authResponse
|
||||
return refreshTokens(
|
||||
this.requestClient,
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,7 +590,7 @@ export class SASViyaApiClient {
|
||||
) {
|
||||
let access_token = (authConfig || {}).access_token
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
;({ access_token } = await getTokens(this.requestClient, authConfig))
|
||||
}
|
||||
|
||||
if (isRelativePath(sasJob) && !this.rootFolderName) {
|
||||
@@ -988,7 +686,7 @@ export class SASViyaApiClient {
|
||||
) {
|
||||
let access_token = (authConfig || {}).access_token
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
;({ access_token } = await getTokens(this.requestClient, authConfig))
|
||||
}
|
||||
if (isRelativePath(sasJob) && !this.rootFolderName) {
|
||||
throw new Error(
|
||||
@@ -1060,18 +758,16 @@ export class SASViyaApiClient {
|
||||
jobDefinition,
|
||||
arguments: jobArguments
|
||||
}
|
||||
const { result: postedJob, etag } = await this.requestClient.post<Job>(
|
||||
const { result: postedJob } = await this.requestClient.post<Job>(
|
||||
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
|
||||
postJobRequestBody,
|
||||
access_token
|
||||
)
|
||||
const jobStatus = await this.pollJobState(
|
||||
postedJob,
|
||||
etag,
|
||||
authConfig
|
||||
).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while polling job status. ')
|
||||
})
|
||||
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
|
||||
(err) => {
|
||||
throw prefixMessage(err, 'Error while polling job status. ')
|
||||
}
|
||||
)
|
||||
const { result: currentJob } = await this.requestClient.get<Job>(
|
||||
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
|
||||
access_token
|
||||
@@ -1137,157 +833,22 @@ export class SASViyaApiClient {
|
||||
this.folderMap.set(path, itemsAtRoot)
|
||||
}
|
||||
|
||||
// REFACTOR: set default value for 'pollOptions' attribute
|
||||
private async pollJobState(
|
||||
postedJob: any,
|
||||
etag: string | null,
|
||||
postedJob: Job,
|
||||
authConfig?: AuthConfig,
|
||||
pollOptions?: PollOptions
|
||||
) {
|
||||
const logger = process.logger || console
|
||||
|
||||
let POLL_INTERVAL = 300
|
||||
let MAX_POLL_COUNT = 1000
|
||||
let MAX_ERROR_COUNT = 5
|
||||
let access_token = (authConfig || {}).access_token
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
}
|
||||
|
||||
if (pollOptions) {
|
||||
POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL
|
||||
MAX_POLL_COUNT = pollOptions.MAX_POLL_COUNT || MAX_POLL_COUNT
|
||||
}
|
||||
|
||||
let postedJobState = ''
|
||||
let pollCount = 0
|
||||
let errorCount = 0
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json',
|
||||
'If-None-Match': etag
|
||||
}
|
||||
if (access_token) {
|
||||
headers.Authorization = `Bearer ${access_token}`
|
||||
}
|
||||
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
|
||||
if (!stateLink) {
|
||||
Promise.reject(`Job state link was not found.`)
|
||||
}
|
||||
|
||||
const { result: state } = await this.requestClient
|
||||
.get<string>(
|
||||
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
|
||||
access_token,
|
||||
'text/plain',
|
||||
{},
|
||||
this.debug
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`Error fetching job state from ${this.serverUrl}${stateLink.href}. Starting poll, assuming job to be running.`,
|
||||
err
|
||||
)
|
||||
return { result: 'unavailable' }
|
||||
})
|
||||
|
||||
const currentState = state.trim()
|
||||
if (currentState === 'completed') {
|
||||
return Promise.resolve(currentState)
|
||||
}
|
||||
|
||||
return new Promise(async (resolve, _) => {
|
||||
let printedState = ''
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (
|
||||
postedJobState === 'running' ||
|
||||
postedJobState === '' ||
|
||||
postedJobState === 'pending' ||
|
||||
postedJobState === 'unavailable'
|
||||
) {
|
||||
if (authConfig) {
|
||||
;({ access_token } = await this.getTokens(authConfig))
|
||||
}
|
||||
|
||||
if (stateLink) {
|
||||
const { result: jobState } = await this.requestClient
|
||||
.get<string>(
|
||||
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
|
||||
access_token,
|
||||
'text/plain',
|
||||
{},
|
||||
this.debug
|
||||
)
|
||||
.catch((err) => {
|
||||
errorCount++
|
||||
if (
|
||||
pollCount >= MAX_POLL_COUNT ||
|
||||
errorCount >= MAX_ERROR_COUNT
|
||||
) {
|
||||
throw prefixMessage(
|
||||
err,
|
||||
'Error while getting job state after interval. '
|
||||
)
|
||||
}
|
||||
console.error(
|
||||
`Error fetching job state from ${this.serverUrl}${stateLink.href}. Resuming poll, assuming job to be running.`,
|
||||
err
|
||||
)
|
||||
return { result: 'unavailable' }
|
||||
})
|
||||
|
||||
postedJobState = jobState.trim()
|
||||
if (postedJobState != 'unavailable' && errorCount > 0) {
|
||||
errorCount = 0
|
||||
}
|
||||
|
||||
if (this.debug && printedState !== postedJobState) {
|
||||
logger.info('Polling job status...')
|
||||
logger.info(`Current job state: ${postedJobState}`)
|
||||
|
||||
printedState = postedJobState
|
||||
}
|
||||
|
||||
pollCount++
|
||||
|
||||
if (pollCount >= MAX_POLL_COUNT) {
|
||||
resolve(postedJobState)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
resolve(postedJobState)
|
||||
}
|
||||
}, POLL_INTERVAL)
|
||||
})
|
||||
return pollJobState(
|
||||
this.requestClient,
|
||||
postedJob,
|
||||
this.debug,
|
||||
authConfig,
|
||||
pollOptions
|
||||
)
|
||||
}
|
||||
|
||||
private async uploadTables(data: any, accessToken?: string) {
|
||||
const uploadedFiles = []
|
||||
const headers: any = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
for (const tableName in data) {
|
||||
const csv = convertToCSV(data[tableName])
|
||||
if (csv === 'ERROR: LARGE STRING LENGTH') {
|
||||
throw new Error(
|
||||
'The max length of a string value in SASjs is 32765 characters.'
|
||||
)
|
||||
}
|
||||
|
||||
const uploadResponse = await this.requestClient
|
||||
.uploadFile(`${this.serverUrl}/files/files#rawUpload`, csv, accessToken)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while uploading file. ')
|
||||
})
|
||||
|
||||
uploadedFiles.push({ tableName, file: uploadResponse.result })
|
||||
}
|
||||
return uploadedFiles
|
||||
return uploadTables(this.requestClient, data, accessToken)
|
||||
}
|
||||
|
||||
private async getFolderDetails(
|
||||
@@ -1376,14 +937,6 @@ export class SASViyaApiClient {
|
||||
? sourceFolder
|
||||
: await this.getFolderUri(sourceFolder, accessToken)
|
||||
|
||||
const requestInfo = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + accessToken
|
||||
}
|
||||
}
|
||||
|
||||
const { result: members } = await this.requestClient.get<{ items: any[] }>(
|
||||
`${this.serverUrl}${sourceFolderUri}/members?limit=${limit}`,
|
||||
accessToken
|
||||
@@ -1490,21 +1043,4 @@ export class SASViyaApiClient {
|
||||
|
||||
return movedFolder
|
||||
}
|
||||
|
||||
private async getTokens(authConfig: AuthConfig): Promise<AuthConfig> {
|
||||
const logger = process.logger || console
|
||||
let { access_token, refresh_token, client, secret } = authConfig
|
||||
if (
|
||||
isAccessTokenExpiring(access_token) ||
|
||||
isRefreshTokenExpiring(refresh_token)
|
||||
) {
|
||||
logger.info('Refreshing access and refresh tokens.')
|
||||
;({ access_token, refresh_token } = await this.refreshTokens(
|
||||
client,
|
||||
secret,
|
||||
refresh_token
|
||||
))
|
||||
}
|
||||
return { access_token, refresh_token, client, secret }
|
||||
}
|
||||
}
|
||||
|
||||
79
src/SASjs.ts
79
src/SASjs.ts
@@ -1,10 +1,21 @@
|
||||
import { compareTimestamps, asyncForEach } from './utils'
|
||||
import { SASjsConfig, UploadFile, EditContextInput, PollOptions } from './types'
|
||||
import {
|
||||
SASjsConfig,
|
||||
UploadFile,
|
||||
EditContextInput,
|
||||
PollOptions,
|
||||
LoginMechanism
|
||||
} from './types'
|
||||
import { SASViyaApiClient } from './SASViyaApiClient'
|
||||
import { SAS9ApiClient } from './SAS9ApiClient'
|
||||
import { FileUploader } from './FileUploader'
|
||||
import { AuthManager } from './auth'
|
||||
import { ServerType, MacroVar, AuthConfig } from '@sasjs/utils/types'
|
||||
import {
|
||||
ServerType,
|
||||
MacroVar,
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes
|
||||
} from '@sasjs/utils/types'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
import {
|
||||
JobExecutor,
|
||||
@@ -14,7 +25,7 @@ import {
|
||||
Sas9JobExecutor
|
||||
} from './job-execution'
|
||||
import { ErrorResponse } from './types/errors'
|
||||
import { ExtraResponseAttributes } from '@sasjs/utils/types'
|
||||
import { LoginOptions, LoginResult } from './types/Login'
|
||||
|
||||
const defaultConfig: SASjsConfig = {
|
||||
serverUrl: '',
|
||||
@@ -25,7 +36,8 @@ const defaultConfig: SASjsConfig = {
|
||||
debug: false,
|
||||
contextName: 'SAS Job Execution compute context',
|
||||
useComputeApi: null,
|
||||
allowInsecureRequests: false
|
||||
allowInsecureRequests: false,
|
||||
loginMechanism: LoginMechanism.Default
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,8 +534,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,11 +571,22 @@ 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)
|
||||
*/
|
||||
public uploadFile(sasJob: string, files: UploadFile[], params: any) {
|
||||
const fileUploader =
|
||||
this.fileUploader ||
|
||||
new FileUploader(this.sasjsConfig, this.jobsPath, this.requestClient!)
|
||||
public uploadFile(
|
||||
sasJob: string,
|
||||
files: UploadFile[],
|
||||
params: any,
|
||||
overrideSasjsConfig?: any
|
||||
) {
|
||||
const fileUploader = overrideSasjsConfig
|
||||
? new FileUploader(
|
||||
{ ...this.sasjsConfig, ...overrideSasjsConfig },
|
||||
this.jobsPath,
|
||||
this.requestClient!
|
||||
)
|
||||
: this.fileUploader ||
|
||||
new FileUploader(this.sasjsConfig, this.jobsPath, this.requestClient!)
|
||||
|
||||
return fileUploader.uploadFile(sasJob, files, params)
|
||||
}
|
||||
@@ -604,6 +646,11 @@ export default class SASjs {
|
||||
authConfig
|
||||
)
|
||||
} else {
|
||||
if (!config.contextName)
|
||||
config = {
|
||||
...config,
|
||||
contextName: 'SAS Job Execution compute context'
|
||||
}
|
||||
return await this.jesJobExecutor!.execute(
|
||||
sasJob,
|
||||
data,
|
||||
@@ -734,7 +781,11 @@ export default class SASjs {
|
||||
)
|
||||
sasApiClient.debug = this.sasjsConfig.debug
|
||||
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
|
||||
sasApiClient = new SAS9ApiClient(serverUrl, this.jobsPath)
|
||||
sasApiClient = new SAS9ApiClient(
|
||||
serverUrl,
|
||||
this.jobsPath,
|
||||
this.sasjsConfig.allowInsecureRequests
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let sasClientConfig: any = null
|
||||
@@ -929,7 +980,8 @@ export default class SASjs {
|
||||
else
|
||||
this.sas9ApiClient = new SAS9ApiClient(
|
||||
this.sasjsConfig.serverUrl,
|
||||
this.jobsPath
|
||||
this.jobsPath,
|
||||
this.sasjsConfig.allowInsecureRequests
|
||||
)
|
||||
}
|
||||
|
||||
@@ -950,7 +1002,8 @@ export default class SASjs {
|
||||
this.sas9JobExecutor = new Sas9JobExecutor(
|
||||
this.sasjsConfig.serverUrl,
|
||||
this.sasjsConfig.serverType!,
|
||||
this.jobsPath
|
||||
this.jobsPath,
|
||||
this.sasjsConfig.allowInsecureRequests
|
||||
)
|
||||
|
||||
this.computeJobExecutor = new ComputeJobExecutor(
|
||||
|
||||
@@ -5,10 +5,10 @@ import { prefixMessage } from '@sasjs/utils/error'
|
||||
import { RequestClient } from './request/RequestClient'
|
||||
|
||||
const MAX_SESSION_COUNT = 1
|
||||
const RETRY_LIMIT: number = 3
|
||||
let RETRY_COUNT: number = 0
|
||||
|
||||
export class SessionManager {
|
||||
private loggedErrors: NoSessionStateError[] = []
|
||||
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private contextName: string,
|
||||
@@ -154,69 +154,75 @@ export class SessionManager {
|
||||
session: Session,
|
||||
etag: string | null,
|
||||
accessToken?: string
|
||||
) {
|
||||
): Promise<string> {
|
||||
const logger = process.logger || console
|
||||
|
||||
let sessionState = session.state
|
||||
|
||||
const stateLink = session.links.find((l: any) => l.rel === 'state')
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if (
|
||||
sessionState === 'pending' ||
|
||||
sessionState === 'running' ||
|
||||
sessionState === ''
|
||||
) {
|
||||
if (stateLink) {
|
||||
if (this.debug && !this.printedSessionState.printed) {
|
||||
logger.info('Polling session status...')
|
||||
if (
|
||||
sessionState === 'pending' ||
|
||||
sessionState === 'running' ||
|
||||
sessionState === ''
|
||||
) {
|
||||
if (stateLink) {
|
||||
if (this.debug && !this.printedSessionState.printed) {
|
||||
logger.info('Polling session status...')
|
||||
|
||||
this.printedSessionState.printed = true
|
||||
}
|
||||
|
||||
const { result: state, responseStatus: responseStatus } =
|
||||
await this.getSessionState(
|
||||
`${this.serverUrl}${stateLink.href}?wait=30`,
|
||||
etag!,
|
||||
accessToken
|
||||
).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session state.')
|
||||
})
|
||||
|
||||
sessionState = state.trim()
|
||||
|
||||
if (this.debug && this.printedSessionState.state !== sessionState) {
|
||||
logger.info(`Current session state is '${sessionState}'`)
|
||||
|
||||
this.printedSessionState.state = sessionState
|
||||
this.printedSessionState.printed = false
|
||||
}
|
||||
|
||||
// There is an internal error present in SAS Viya 3.5
|
||||
// Retry to wait for a session status in such case of SAS internal error
|
||||
if (!sessionState) {
|
||||
if (RETRY_COUNT < RETRY_LIMIT) {
|
||||
RETRY_COUNT++
|
||||
|
||||
resolve(this.waitForSession(session, etag, accessToken))
|
||||
} else {
|
||||
reject(
|
||||
new NoSessionStateError(
|
||||
responseStatus,
|
||||
this.serverUrl + stateLink.href,
|
||||
session.links.find((l: any) => l.rel === 'log')
|
||||
?.href as string
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolve(sessionState)
|
||||
this.printedSessionState.printed = true
|
||||
}
|
||||
|
||||
const { result: state, responseStatus: responseStatus } =
|
||||
await this.getSessionState(
|
||||
`${this.serverUrl}${stateLink.href}?wait=30`,
|
||||
etag!,
|
||||
accessToken
|
||||
).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session state.')
|
||||
})
|
||||
|
||||
sessionState = state.trim()
|
||||
|
||||
if (this.debug && this.printedSessionState.state !== sessionState) {
|
||||
logger.info(`Current session state is '${sessionState}'`)
|
||||
|
||||
this.printedSessionState.state = sessionState
|
||||
this.printedSessionState.printed = false
|
||||
}
|
||||
|
||||
if (!sessionState) {
|
||||
const stateError = new NoSessionStateError(
|
||||
responseStatus,
|
||||
this.serverUrl + stateLink.href,
|
||||
session.links.find((l: any) => l.rel === 'log')?.href as string
|
||||
)
|
||||
|
||||
if (
|
||||
!this.loggedErrors.find(
|
||||
(err: NoSessionStateError) =>
|
||||
err.serverResponseStatus === stateError.serverResponseStatus
|
||||
)
|
||||
) {
|
||||
this.loggedErrors.push(stateError)
|
||||
|
||||
logger.info(stateError.message)
|
||||
}
|
||||
|
||||
return await this.waitForSession(session, etag, accessToken)
|
||||
}
|
||||
|
||||
this.loggedErrors = []
|
||||
|
||||
return sessionState
|
||||
} else {
|
||||
resolve(sessionState)
|
||||
throw 'Error while getting session state link.'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.loggedErrors = []
|
||||
|
||||
return sessionState
|
||||
}
|
||||
}
|
||||
|
||||
private async getSessionState(
|
||||
|
||||
293
src/api/viya/executeScript.ts
Normal file
293
src/api/viya/executeScript.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
|
||||
import { AuthConfig, MacroVar } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import {
|
||||
PollOptions,
|
||||
Job,
|
||||
ComputeJobExecutionError,
|
||||
NotFoundError
|
||||
} from '../..'
|
||||
import { getTokens } from '../../auth/getTokens'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { SessionManager } from '../../SessionManager'
|
||||
import { isRelativePath, fetchLogByChunks } from '../../utils'
|
||||
import { formatDataForRequest } from '../../utils/formatDataForRequest'
|
||||
import { pollJobState } from './pollJobState'
|
||||
import { uploadTables } from './uploadTables'
|
||||
|
||||
/**
|
||||
* Executes code on the current SAS Viya server.
|
||||
* @param jobPath - the path to the file being submitted for execution.
|
||||
* @param linesOfCode - an array of code lines to execute.
|
||||
* @param contextName - the context to execute the code in.
|
||||
* @param authConfig - an object containing an access token, refresh token, client ID and secret.
|
||||
* @param data - execution data.
|
||||
* @param debug - when set to true, the log will be returned.
|
||||
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
|
||||
* @param waitForResult - when set to true, function will return the session
|
||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
|
||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||
* @param variables - an object that represents macro variables.
|
||||
*/
|
||||
export async function executeScript(
|
||||
requestClient: RequestClient,
|
||||
sessionManager: SessionManager,
|
||||
rootFolderName: string,
|
||||
jobPath: string,
|
||||
linesOfCode: string[],
|
||||
contextName: string,
|
||||
authConfig?: AuthConfig,
|
||||
data: any = null,
|
||||
debug: boolean = false,
|
||||
expectWebout = false,
|
||||
waitForResult = true,
|
||||
pollOptions?: PollOptions,
|
||||
printPid = false,
|
||||
variables?: MacroVar
|
||||
): Promise<any> {
|
||||
let access_token = (authConfig || {}).access_token
|
||||
if (authConfig) {
|
||||
;({ access_token } = await getTokens(requestClient, authConfig))
|
||||
}
|
||||
|
||||
const logger = process.logger || console
|
||||
|
||||
try {
|
||||
let executionSessionId: string
|
||||
|
||||
const session = await sessionManager
|
||||
.getSession(access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session. ')
|
||||
})
|
||||
|
||||
executionSessionId = session!.id
|
||||
|
||||
if (printPid) {
|
||||
const { result: jobIdVariable } = await sessionManager
|
||||
.getVariable(executionSessionId, 'SYSJOBID', access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting session variable. ')
|
||||
})
|
||||
|
||||
if (jobIdVariable && jobIdVariable.value) {
|
||||
const relativeJobPath = rootFolderName
|
||||
? jobPath.split(rootFolderName).join('').replace(/^\//, '')
|
||||
: jobPath
|
||||
|
||||
const logger = process.logger || console
|
||||
|
||||
logger.info(
|
||||
`Triggered '${relativeJobPath}' with PID ${
|
||||
jobIdVariable.value
|
||||
} at ${timestampToYYYYMMDDHHMMSS()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const jobArguments: { [key: string]: any } = {
|
||||
_contextName: contextName,
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: true,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: true
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
jobArguments['_OMITTEXTLOG'] = false
|
||||
jobArguments['_OMITSESSIONRESULTS'] = false
|
||||
}
|
||||
|
||||
let fileName
|
||||
|
||||
if (isRelativePath(jobPath)) {
|
||||
fileName = `exec-${
|
||||
jobPath.includes('/') ? jobPath.split('/')[1] : jobPath
|
||||
}`
|
||||
} else {
|
||||
const jobPathParts = jobPath.split('/')
|
||||
fileName = jobPathParts.pop()
|
||||
}
|
||||
|
||||
let jobVariables: any = {
|
||||
SYS_JES_JOB_URI: '',
|
||||
_program: isRelativePath(jobPath)
|
||||
? rootFolderName + '/' + jobPath
|
||||
: jobPath
|
||||
}
|
||||
|
||||
if (variables) jobVariables = { ...jobVariables, ...variables }
|
||||
|
||||
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
||||
|
||||
let files: any[] = []
|
||||
|
||||
if (data) {
|
||||
if (JSON.stringify(data).includes(';')) {
|
||||
files = await uploadTables(requestClient, data, access_token).catch(
|
||||
(err) => {
|
||||
throw prefixMessage(err, 'Error while uploading tables. ')
|
||||
}
|
||||
)
|
||||
|
||||
jobVariables['_webin_file_count'] = files.length
|
||||
|
||||
files.forEach((fileInfo, index) => {
|
||||
jobVariables[
|
||||
`_webin_fileuri${index + 1}`
|
||||
] = `/files/files/${fileInfo.file.id}`
|
||||
jobVariables[`_webin_name${index + 1}`] = fileInfo.tableName
|
||||
})
|
||||
} else {
|
||||
jobVariables = { ...jobVariables, ...formatDataForRequest(data) }
|
||||
}
|
||||
}
|
||||
|
||||
// Execute job in session
|
||||
const jobRequestBody = {
|
||||
name: fileName,
|
||||
description: 'Powered by SASjs',
|
||||
code: linesOfCode,
|
||||
variables: jobVariables,
|
||||
arguments: jobArguments
|
||||
}
|
||||
|
||||
const { result: postedJob, etag } = await requestClient
|
||||
.post<Job>(
|
||||
`/compute/sessions/${executionSessionId}/jobs`,
|
||||
jobRequestBody,
|
||||
access_token
|
||||
)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while posting job. ')
|
||||
})
|
||||
|
||||
if (!waitForResult) return session
|
||||
|
||||
if (debug) {
|
||||
logger.info(`Job has been submitted for '${fileName}'.`)
|
||||
logger.info(
|
||||
`You can monitor the job progress at '${requestClient.getBaseUrl()}${
|
||||
postedJob.links.find((l: any) => l.rel === 'state')!.href
|
||||
}'.`
|
||||
)
|
||||
}
|
||||
|
||||
const jobStatus = await pollJobState(
|
||||
requestClient,
|
||||
postedJob,
|
||||
debug,
|
||||
authConfig,
|
||||
pollOptions
|
||||
).catch(async (err) => {
|
||||
const error = err?.response?.data
|
||||
const result = /err=[0-9]*,/.exec(error)
|
||||
|
||||
const errorCode = '5113'
|
||||
if (result?.[0]?.slice(4, -1) === errorCode) {
|
||||
const sessionLogUrl =
|
||||
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
||||
const logCount = 1000000
|
||||
err.log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
sessionLogUrl,
|
||||
logCount
|
||||
)
|
||||
}
|
||||
throw prefixMessage(err, 'Error while polling job status. ')
|
||||
})
|
||||
|
||||
if (authConfig) {
|
||||
;({ access_token } = await getTokens(requestClient, authConfig))
|
||||
}
|
||||
|
||||
const { result: currentJob } = await requestClient
|
||||
.get<Job>(
|
||||
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
|
||||
access_token
|
||||
)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting job. ')
|
||||
})
|
||||
|
||||
let jobResult
|
||||
let log = ''
|
||||
|
||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||
|
||||
if (debug && logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
logUrl,
|
||||
logCount
|
||||
)
|
||||
}
|
||||
|
||||
if (jobStatus === 'failed' || jobStatus === 'error') {
|
||||
throw new ComputeJobExecutionError(currentJob, log)
|
||||
}
|
||||
|
||||
if (!expectWebout) {
|
||||
return { job: currentJob, log }
|
||||
}
|
||||
|
||||
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
||||
|
||||
jobResult = await requestClient
|
||||
.get<any>(resultLink, access_token, 'text/plain')
|
||||
.catch(async (e) => {
|
||||
if (e instanceof NotFoundError) {
|
||||
if (logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
logUrl,
|
||||
logCount
|
||||
)
|
||||
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
log
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: JSON.stringify(e)
|
||||
}
|
||||
})
|
||||
|
||||
await sessionManager
|
||||
.clearSession(executionSessionId, access_token)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while clearing session. ')
|
||||
})
|
||||
|
||||
return { result: jobResult?.result, log }
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
return executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
rootFolderName,
|
||||
jobPath,
|
||||
linesOfCode,
|
||||
contextName,
|
||||
authConfig,
|
||||
data,
|
||||
debug,
|
||||
false,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
throw prefixMessage(e, 'Error while executing script. ')
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/api/viya/getFileStream.ts
Normal file
17
src/api/viya/getFileStream.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { isFolder } from '@sasjs/utils/file'
|
||||
import { generateTimestamp } from '@sasjs/utils/time'
|
||||
import { Job } from '../../types'
|
||||
|
||||
export const getFileStream = async (job: Job, filePath?: string) => {
|
||||
const { createWriteStream } = require('@sasjs/utils/file')
|
||||
const logPath = filePath || process.cwd()
|
||||
const isFolderPath = await isFolder(logPath)
|
||||
if (isFolderPath) {
|
||||
const logFileName = `${job.name || 'job'}-${generateTimestamp()}.log`
|
||||
const path = require('path')
|
||||
const logFilePath = path.join(filePath || process.cwd(), logFileName)
|
||||
return await createWriteStream(logFilePath)
|
||||
} else {
|
||||
return await createWriteStream(logPath)
|
||||
}
|
||||
}
|
||||
248
src/api/viya/pollJobState.ts
Normal file
248
src/api/viya/pollJobState.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { AuthConfig } from '@sasjs/utils/types'
|
||||
import { Job, PollOptions } from '../..'
|
||||
import { getTokens } from '../../auth/getTokens'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { JobStatePollError } from '../../types/errors'
|
||||
import { Link, WriteStream } from '../../types'
|
||||
import { delay, isNode } from '../../utils'
|
||||
|
||||
export async function pollJobState(
|
||||
requestClient: RequestClient,
|
||||
postedJob: Job,
|
||||
debug: boolean,
|
||||
authConfig?: AuthConfig,
|
||||
pollOptions?: PollOptions
|
||||
) {
|
||||
const logger = process.logger || console
|
||||
|
||||
let pollInterval = 300
|
||||
let maxPollCount = 1000
|
||||
|
||||
const defaultPollOptions: PollOptions = {
|
||||
maxPollCount,
|
||||
pollInterval,
|
||||
streamLog: false
|
||||
}
|
||||
|
||||
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
|
||||
|
||||
const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
|
||||
if (!stateLink) {
|
||||
throw new Error(`Job state link was not found.`)
|
||||
}
|
||||
|
||||
let currentState = await getJobState(
|
||||
requestClient,
|
||||
postedJob,
|
||||
'',
|
||||
debug,
|
||||
authConfig
|
||||
).catch((err) => {
|
||||
logger.error(
|
||||
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
|
||||
err
|
||||
)
|
||||
return 'unavailable'
|
||||
})
|
||||
|
||||
let pollCount = 0
|
||||
|
||||
if (currentState === 'completed') {
|
||||
return Promise.resolve(currentState)
|
||||
}
|
||||
|
||||
let logFileStream
|
||||
if (pollOptions.streamLog && isNode()) {
|
||||
const { getFileStream } = require('./getFileStream')
|
||||
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
|
||||
}
|
||||
|
||||
// Poll up to the first 100 times with the specified poll interval
|
||||
let result = await doPoll(
|
||||
requestClient,
|
||||
postedJob,
|
||||
currentState,
|
||||
debug,
|
||||
pollCount,
|
||||
authConfig,
|
||||
{
|
||||
...pollOptions,
|
||||
maxPollCount:
|
||||
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
|
||||
},
|
||||
logFileStream
|
||||
)
|
||||
|
||||
currentState = result.state
|
||||
pollCount = result.pollCount
|
||||
|
||||
if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) {
|
||||
return currentState
|
||||
}
|
||||
|
||||
// If we get to this point, this is a long-running job that needs longer polling.
|
||||
// We will resume polling with a bigger interval of 1 minute
|
||||
let longJobPollOptions: PollOptions = {
|
||||
maxPollCount: 24 * 60,
|
||||
pollInterval: 60000,
|
||||
streamLog: false
|
||||
}
|
||||
if (pollOptions) {
|
||||
longJobPollOptions.streamLog = pollOptions.streamLog
|
||||
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
|
||||
}
|
||||
|
||||
result = await doPoll(
|
||||
requestClient,
|
||||
postedJob,
|
||||
currentState,
|
||||
debug,
|
||||
pollCount,
|
||||
authConfig,
|
||||
longJobPollOptions,
|
||||
logFileStream
|
||||
)
|
||||
|
||||
currentState = result.state
|
||||
pollCount = result.pollCount
|
||||
|
||||
if (logFileStream) {
|
||||
logFileStream.end()
|
||||
}
|
||||
|
||||
return currentState
|
||||
}
|
||||
|
||||
const getJobState = async (
|
||||
requestClient: RequestClient,
|
||||
job: Job,
|
||||
currentState: string,
|
||||
debug: boolean,
|
||||
authConfig?: AuthConfig
|
||||
) => {
|
||||
const stateLink = job.links.find((l: any) => l.rel === 'state')
|
||||
if (!stateLink) {
|
||||
throw new Error(`Job state link was not found.`)
|
||||
}
|
||||
|
||||
if (needsRetry(currentState)) {
|
||||
let tokens
|
||||
if (authConfig) {
|
||||
tokens = await getTokens(requestClient, authConfig)
|
||||
}
|
||||
|
||||
const { result: jobState } = await requestClient
|
||||
.get<string>(
|
||||
`${stateLink.href}?_action=wait&wait=300`,
|
||||
tokens?.access_token,
|
||||
'text/plain',
|
||||
{},
|
||||
debug
|
||||
)
|
||||
.catch((err) => {
|
||||
throw new JobStatePollError(job.id, err)
|
||||
})
|
||||
|
||||
return jobState.trim()
|
||||
} else {
|
||||
return currentState
|
||||
}
|
||||
}
|
||||
|
||||
const needsRetry = (state: string) =>
|
||||
state === 'running' ||
|
||||
state === '' ||
|
||||
state === 'pending' ||
|
||||
state === 'unavailable'
|
||||
|
||||
const doPoll = async (
|
||||
requestClient: RequestClient,
|
||||
postedJob: Job,
|
||||
currentState: string,
|
||||
debug: boolean,
|
||||
pollCount: number,
|
||||
authConfig?: AuthConfig,
|
||||
pollOptions?: PollOptions,
|
||||
logStream?: WriteStream
|
||||
): Promise<{ state: string; pollCount: number }> => {
|
||||
let pollInterval = 300
|
||||
let maxPollCount = 1000
|
||||
let maxErrorCount = 5
|
||||
let errorCount = 0
|
||||
let state = currentState
|
||||
let printedState = ''
|
||||
let startLogLine = 0
|
||||
|
||||
const logger = process.logger || console
|
||||
|
||||
if (pollOptions) {
|
||||
pollInterval = pollOptions.pollInterval || pollInterval
|
||||
maxPollCount = pollOptions.maxPollCount || maxPollCount
|
||||
}
|
||||
|
||||
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
|
||||
if (!stateLink) {
|
||||
throw new Error(`Job state link was not found.`)
|
||||
}
|
||||
|
||||
while (needsRetry(state) && pollCount <= maxPollCount) {
|
||||
state = await getJobState(
|
||||
requestClient,
|
||||
postedJob,
|
||||
state,
|
||||
debug,
|
||||
authConfig
|
||||
).catch((err) => {
|
||||
errorCount++
|
||||
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
|
||||
throw err
|
||||
}
|
||||
logger.error(
|
||||
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
|
||||
err
|
||||
)
|
||||
return 'unavailable'
|
||||
})
|
||||
|
||||
pollCount++
|
||||
|
||||
if (pollOptions?.streamLog) {
|
||||
const jobUrl = postedJob.links.find((l: Link) => l.rel === 'self')
|
||||
const { result: job } = await requestClient.get<Job>(
|
||||
jobUrl!.href,
|
||||
authConfig?.access_token
|
||||
)
|
||||
|
||||
const endLogLine = job.logStatistics?.lineCount ?? 1000000
|
||||
|
||||
const { saveLog } = isNode() ? require('./saveLog') : { saveLog: null }
|
||||
if (saveLog) {
|
||||
await saveLog(
|
||||
postedJob,
|
||||
requestClient,
|
||||
startLogLine,
|
||||
endLogLine,
|
||||
logStream,
|
||||
authConfig?.access_token
|
||||
)
|
||||
}
|
||||
|
||||
startLogLine += endLogLine
|
||||
}
|
||||
|
||||
if (debug && printedState !== state) {
|
||||
logger.info('Polling job status...')
|
||||
logger.info(`Current job state: ${state}`)
|
||||
|
||||
printedState = state
|
||||
}
|
||||
|
||||
if (state != 'unavailable' && errorCount > 0) {
|
||||
errorCount = 0
|
||||
}
|
||||
|
||||
await delay(pollInterval)
|
||||
}
|
||||
|
||||
return { state, pollCount }
|
||||
}
|
||||
55
src/api/viya/saveLog.ts
Normal file
55
src/api/viya/saveLog.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Job } from '../..'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { fetchLog } from '../../utils'
|
||||
import { WriteStream } from '../../types'
|
||||
import { writeStream } from './writeStream'
|
||||
|
||||
/**
|
||||
* Appends logs to a supplied write stream.
|
||||
* This is useful for getting quick feedback on longer running jobs.
|
||||
* @param job - the job to fetch logs for
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param startLine - the line at which to start fetching the log
|
||||
* @param endLine - the line at which to stop fetching the log
|
||||
* @param logFileStream - the write stream to which the log is appended
|
||||
* @accessToken - an optional access token for authentication/authorization
|
||||
* The access token is not required when fetching logs from the browser.
|
||||
*/
|
||||
export async function saveLog(
|
||||
job: Job,
|
||||
requestClient: RequestClient,
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
logFileStream?: WriteStream,
|
||||
accessToken?: string
|
||||
) {
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
`Logs for job ${job.id} cannot be fetched without a valid access token.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!logFileStream) {
|
||||
throw new Error(
|
||||
`Logs for job ${job.id} cannot be written without a valid write stream.`
|
||||
)
|
||||
}
|
||||
|
||||
const logger = process.logger || console
|
||||
const jobLogUrl = job.links.find((l) => l.rel === 'log')
|
||||
|
||||
if (!jobLogUrl) {
|
||||
throw new Error(`Log URL for job ${job.id} was not found.`)
|
||||
}
|
||||
|
||||
const log = await fetchLog(
|
||||
requestClient,
|
||||
accessToken,
|
||||
`${jobLogUrl.href}/content`,
|
||||
startLine,
|
||||
endLine
|
||||
)
|
||||
|
||||
logger.info(`Writing logs to ${logFileStream.path}`)
|
||||
await writeStream(logFileStream, log || '')
|
||||
}
|
||||
675
src/api/viya/spec/executeScript.spec.ts
Normal file
675
src/api/viya/spec/executeScript.spec.ts
Normal file
@@ -0,0 +1,675 @@
|
||||
import { RequestClient } from '../../../request/RequestClient'
|
||||
import { SessionManager } from '../../../SessionManager'
|
||||
import { executeScript } from '../executeScript'
|
||||
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
|
||||
import * as pollJobStateModule from '../pollJobState'
|
||||
import * as uploadTablesModule from '../uploadTables'
|
||||
import * as getTokensModule from '../../../auth/getTokens'
|
||||
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||
import { PollOptions } from '../../../types'
|
||||
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
||||
import { Logger, LogLevel } from '@sasjs/utils'
|
||||
|
||||
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
const defaultPollOptions: PollOptions = {
|
||||
maxPollCount: 100,
|
||||
pollInterval: 500,
|
||||
streamLog: false
|
||||
}
|
||||
|
||||
describe('executeScript', () => {
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context'
|
||||
)
|
||||
|
||||
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should try to get fresh tokens if an authConfig is provided', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context',
|
||||
mockAuthConfig
|
||||
)
|
||||
|
||||
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig
|
||||
)
|
||||
})
|
||||
|
||||
it('should get a session from the session manager before executing', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context'
|
||||
)
|
||||
|
||||
expect(sessionManager.getSession).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should handle errors while getting a session', async () => {
|
||||
jest
|
||||
.spyOn(sessionManager, 'getSession')
|
||||
.mockImplementation(() => Promise.reject('Test Error'))
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context'
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while getting session.')
|
||||
})
|
||||
|
||||
it('should fetch the PID when printPid is true', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(sessionManager.getVariable).toHaveBeenCalledWith(
|
||||
mockSession.id,
|
||||
'SYSJOBID',
|
||||
mockAuthConfig.access_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors while getting the job PID', async () => {
|
||||
jest
|
||||
.spyOn(sessionManager, 'getVariable')
|
||||
.mockImplementation(() => Promise.reject('Test Error'))
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while getting session variable.')
|
||||
})
|
||||
|
||||
it('should use the file upload approach when data contains semicolons', async () => {
|
||||
jest
|
||||
.spyOn(uploadTablesModule, 'uploadTables')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
|
||||
)
|
||||
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar;' },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(uploadTablesModule.uploadTables).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
{ foo: 'bar;' },
|
||||
mockAuthConfig.access_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should format data as CSV when it does not contain semicolons', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put hello'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(formatDataModule.formatDataForRequest).toHaveBeenCalledWith({
|
||||
foo: 'bar'
|
||||
})
|
||||
})
|
||||
|
||||
it('should submit a job for execution via the compute API', async () => {
|
||||
jest
|
||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
`/compute/sessions/${mockSession.id}/jobs`,
|
||||
{
|
||||
name: 'exec-test',
|
||||
description: 'Powered by SASjs',
|
||||
code: ['%put "hello";'],
|
||||
variables: {
|
||||
SYS_JES_JOB_URI: '',
|
||||
_program: 'test/test',
|
||||
sasjs_tables: 'foo',
|
||||
sasjs0data: 'bar'
|
||||
},
|
||||
arguments: {
|
||||
_contextName: 'test context',
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: true,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: true
|
||||
}
|
||||
},
|
||||
mockAuthConfig.access_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should set the correct variables when debug is true', async () => {
|
||||
jest
|
||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
`/compute/sessions/${mockSession.id}/jobs`,
|
||||
{
|
||||
name: 'exec-test',
|
||||
description: 'Powered by SASjs',
|
||||
code: ['%put "hello";'],
|
||||
variables: {
|
||||
SYS_JES_JOB_URI: '',
|
||||
_program: 'test/test',
|
||||
sasjs_tables: 'foo',
|
||||
sasjs0data: 'bar',
|
||||
_DEBUG: 131
|
||||
},
|
||||
arguments: {
|
||||
_contextName: 'test context',
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: false,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: false
|
||||
}
|
||||
},
|
||||
mockAuthConfig.access_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors during job submission', async () => {
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() => Promise.reject('Test Error'))
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while posting job')
|
||||
})
|
||||
|
||||
it('should immediately return the session when waitForResult is false', async () => {
|
||||
const result = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(result).toEqual(mockSession)
|
||||
})
|
||||
|
||||
it('should poll for job completion when waitForResult is true', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(pollJobStateModule.pollJobState).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
defaultPollOptions
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle general errors when polling for job status', async () => {
|
||||
jest
|
||||
.spyOn(pollJobStateModule, 'pollJobState')
|
||||
.mockImplementation(() => Promise.reject('Poll Error'))
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while polling job status.')
|
||||
})
|
||||
|
||||
it('should fetch the log and append it to the error in case of a 5113 error code', async () => {
|
||||
jest
|
||||
.spyOn(pollJobStateModule, 'pollJobState')
|
||||
.mockImplementation(() =>
|
||||
Promise.reject({ response: { data: 'err=5113,' } })
|
||||
)
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig.access_token,
|
||||
mockJob.links.find((l) => l.rel === 'up')!.href + '/log',
|
||||
1000000
|
||||
)
|
||||
expect(error.log).toEqual('Test Log')
|
||||
})
|
||||
|
||||
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig.access_token,
|
||||
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
|
||||
mockJob.logStatistics.lineCount
|
||||
)
|
||||
})
|
||||
|
||||
it('should not fetch the logs for the job if debug is false', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw a ComputeJobExecutionError if the job has failed', async () => {
|
||||
jest
|
||||
.spyOn(pollJobStateModule, 'pollJobState')
|
||||
.mockImplementation(() => Promise.resolve('failed'))
|
||||
|
||||
const error: ComputeJobExecutionError = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig.access_token,
|
||||
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
|
||||
mockJob.logStatistics.lineCount
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(ComputeJobExecutionError)
|
||||
expect(error.log).toEqual('Test Log')
|
||||
expect(error.job).toEqual(mockJob)
|
||||
})
|
||||
|
||||
it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
|
||||
jest
|
||||
.spyOn(pollJobStateModule, 'pollJobState')
|
||||
.mockImplementation(() => Promise.resolve('error'))
|
||||
|
||||
const error: ComputeJobExecutionError = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig.access_token,
|
||||
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
|
||||
mockJob.logStatistics.lineCount
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(ComputeJobExecutionError)
|
||||
expect(error.log).toEqual('Test Log')
|
||||
expect(error.job).toEqual(mockJob)
|
||||
})
|
||||
|
||||
it('should fetch the result if expectWebout is true', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(requestClient.get).toHaveBeenCalledWith(
|
||||
`/compute/sessions/${mockSession.id}/filerefs/_webout/content`,
|
||||
mockAuthConfig.access_token,
|
||||
'text/plain'
|
||||
)
|
||||
})
|
||||
|
||||
it('should fetch the logs if the webout file was not found', async () => {
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url, ...rest) => {
|
||||
if (url.includes('_webout')) {
|
||||
return Promise.reject(new NotFoundError(url))
|
||||
}
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
})
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(requestClient.get).toHaveBeenCalledWith(
|
||||
`/compute/sessions/${mockSession.id}/filerefs/_webout/content`,
|
||||
mockAuthConfig.access_token,
|
||||
'text/plain'
|
||||
)
|
||||
|
||||
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig.access_token,
|
||||
mockJob.links.find((l) => l.rel === 'log')!.href + '/content',
|
||||
mockJob.logStatistics.lineCount
|
||||
)
|
||||
|
||||
expect(error.status).toEqual(500)
|
||||
expect(error.log).toEqual('Test Log')
|
||||
})
|
||||
|
||||
it('should clear the session after execution is complete', async () => {
|
||||
await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
)
|
||||
|
||||
expect(sessionManager.clearSession).toHaveBeenCalledWith(
|
||||
mockSession.id,
|
||||
mockAuthConfig.access_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors while clearing a session', async () => {
|
||||
jest
|
||||
.spyOn(sessionManager, 'clearSession')
|
||||
.mockImplementation(() => Promise.reject('Clear Session Error'))
|
||||
|
||||
const error = await executeScript(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
'test',
|
||||
'test',
|
||||
['%put "hello";'],
|
||||
'test context',
|
||||
mockAuthConfig,
|
||||
{ foo: 'bar' },
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
defaultPollOptions,
|
||||
true
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while clearing session.')
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../../request/RequestClient')
|
||||
jest.mock('../../../SessionManager')
|
||||
jest.mock('../../../auth/getTokens')
|
||||
jest.mock('../pollJobState')
|
||||
jest.mock('../uploadTables')
|
||||
jest.mock('../../../utils/formatDataForRequest')
|
||||
jest.mock('../../../utils/fetchLogByChunks')
|
||||
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() => Promise.resolve({ result: mockJob, etag: '' }))
|
||||
jest
|
||||
.spyOn(requestClient, 'get')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
)
|
||||
jest
|
||||
.spyOn(requestClient, 'delete')
|
||||
.mockImplementation(() => Promise.resolve({ result: {}, etag: '' }))
|
||||
jest
|
||||
.spyOn(getTokensModule, 'getTokens')
|
||||
.mockImplementation(() => Promise.resolve(mockAuthConfig))
|
||||
jest
|
||||
.spyOn(pollJobStateModule, 'pollJobState')
|
||||
.mockImplementation(() => Promise.resolve('completed'))
|
||||
jest
|
||||
.spyOn(sessionManager, 'getVariable')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: { value: 'test' }, etag: 'test', status: 200 })
|
||||
)
|
||||
jest
|
||||
.spyOn(sessionManager, 'getSession')
|
||||
.mockImplementation(() => Promise.resolve(mockSession))
|
||||
jest
|
||||
.spyOn(sessionManager, 'clearSession')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
jest
|
||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||
.mockImplementation(() => ({ sasjs_tables: 'test', sasjs0data: 'test' }))
|
||||
jest
|
||||
.spyOn(fetchLogsModule, 'fetchLogByChunks')
|
||||
.mockImplementation(() => Promise.resolve('Test Log'))
|
||||
}
|
||||
41
src/api/viya/spec/getFileStream.spec.ts
Normal file
41
src/api/viya/spec/getFileStream.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
import * as path from 'path'
|
||||
import * as fileModule from '@sasjs/utils/file'
|
||||
import { getFileStream } from '../getFileStream'
|
||||
import { mockJob } from './mockResponses'
|
||||
import { WriteStream } from '../../../types'
|
||||
|
||||
describe('getFileStream', () => {
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
setupMocks()
|
||||
})
|
||||
it('should use the given log path if it points to a file', async () => {
|
||||
const { createWriteStream } = require('@sasjs/utils/file')
|
||||
|
||||
await getFileStream(mockJob, path.join(__dirname, 'test.log'))
|
||||
|
||||
expect(createWriteStream).toHaveBeenCalledWith(
|
||||
path.join(__dirname, 'test.log')
|
||||
)
|
||||
})
|
||||
|
||||
it('should generate a log file path with a timestamp if it points to a folder', async () => {
|
||||
const { createWriteStream } = require('@sasjs/utils/file')
|
||||
|
||||
await getFileStream(mockJob, __dirname)
|
||||
|
||||
expect(createWriteStream).not.toHaveBeenCalledWith(__dirname)
|
||||
expect(createWriteStream).toHaveBeenCalledWith(
|
||||
expect.stringContaining(path.join(__dirname, 'test job-20'))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('@sasjs/utils/file/file')
|
||||
jest
|
||||
.spyOn(fileModule, 'createWriteStream')
|
||||
.mockImplementation(() => Promise.resolve({} as unknown as WriteStream))
|
||||
}
|
||||
73
src/api/viya/spec/mockResponses.ts
Normal file
73
src/api/viya/spec/mockResponses.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { AuthConfig } from '@sasjs/utils/types'
|
||||
import { Job, Session } from '../../../types'
|
||||
|
||||
export const mockSession: Session = {
|
||||
id: 's35510n',
|
||||
state: 'idle',
|
||||
links: [],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 1
|
||||
},
|
||||
creationTimeStamp: new Date().valueOf().toString()
|
||||
}
|
||||
|
||||
export const mockJob: Job = {
|
||||
id: 'j0b',
|
||||
name: 'test job',
|
||||
uri: '/j0b',
|
||||
createdBy: 'test user',
|
||||
results: {
|
||||
'_webout.json': 'test'
|
||||
},
|
||||
logStatistics: {
|
||||
lineCount: 100,
|
||||
modifiedTimeStamp: new Date().valueOf().toString()
|
||||
},
|
||||
links: [
|
||||
{
|
||||
rel: 'log',
|
||||
href: '/log',
|
||||
method: 'GET',
|
||||
type: 'log',
|
||||
uri: 'log'
|
||||
},
|
||||
{
|
||||
rel: 'self',
|
||||
href: '/job',
|
||||
method: 'GET',
|
||||
type: 'job',
|
||||
uri: 'job'
|
||||
},
|
||||
{
|
||||
rel: 'state',
|
||||
href: '/state',
|
||||
method: 'GET',
|
||||
type: 'state',
|
||||
uri: 'state'
|
||||
},
|
||||
{
|
||||
rel: 'up',
|
||||
href: '/job',
|
||||
method: 'GET',
|
||||
type: 'up',
|
||||
uri: 'job'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const mockAuthConfig: AuthConfig = {
|
||||
client: 'cl13nt',
|
||||
secret: '53cr3t',
|
||||
access_token: 'acc355',
|
||||
refresh_token: 'r3fr35h'
|
||||
}
|
||||
|
||||
export class MockStream {
|
||||
_write(chunk: string, _: any, next: Function) {
|
||||
next()
|
||||
}
|
||||
|
||||
reset() {}
|
||||
|
||||
destroy() {}
|
||||
}
|
||||
346
src/api/viya/spec/pollJobState.spec.ts
Normal file
346
src/api/viya/spec/pollJobState.spec.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { Logger, LogLevel } from '@sasjs/utils'
|
||||
import { RequestClient } from '../../../request/RequestClient'
|
||||
import { mockAuthConfig, mockJob } from './mockResponses'
|
||||
import { pollJobState } from '../pollJobState'
|
||||
import * as getTokensModule from '../../../auth/getTokens'
|
||||
import * as saveLogModule from '../saveLog'
|
||||
import * as getFileStreamModule from '../getFileStream'
|
||||
import * as isNodeModule from '../../../utils/isNode'
|
||||
import { PollOptions } from '../../../types'
|
||||
import { WriteStream } from 'fs'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
const defaultPollOptions: PollOptions = {
|
||||
maxPollCount: 100,
|
||||
pollInterval: 500,
|
||||
streamLog: false
|
||||
}
|
||||
|
||||
describe('pollJobState', () => {
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
it('should get valid tokens if the authConfig has been provided', async () => {
|
||||
await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(getTokensModule.getTokens).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
mockAuthConfig
|
||||
)
|
||||
})
|
||||
|
||||
it('should not attempt to get tokens if the authConfig has not been provided', async () => {
|
||||
await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(getTokensModule.getTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw an error if the job does not have a state link', async () => {
|
||||
const error = await pollJobState(
|
||||
requestClient,
|
||||
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
|
||||
false,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
).catch((e) => e)
|
||||
|
||||
expect((error as Error).message).toContain('Job state link was not found.')
|
||||
})
|
||||
|
||||
it('should attempt to refresh tokens before each poll', async () => {
|
||||
mockSimplePoll()
|
||||
|
||||
await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should attempt to fetch and save the log after each poll when streamLog is true', async () => {
|
||||
mockSimplePoll()
|
||||
const { saveLog } = require('../saveLog')
|
||||
|
||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||
...defaultPollOptions,
|
||||
streamLog: true
|
||||
})
|
||||
|
||||
expect(saveLog).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should create a write stream in Node.js environment when streamLog is true', async () => {
|
||||
mockSimplePoll()
|
||||
const { getFileStream } = require('../getFileStream')
|
||||
const { saveLog } = require('../saveLog')
|
||||
|
||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||
...defaultPollOptions,
|
||||
streamLog: true
|
||||
})
|
||||
|
||||
expect(getFileStream).toHaveBeenCalled()
|
||||
expect(saveLog).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not create a write stream in a non-Node.js environment', async () => {
|
||||
mockSimplePoll()
|
||||
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
|
||||
const { saveLog } = require('../saveLog')
|
||||
const { getFileStream } = require('../getFileStream')
|
||||
|
||||
await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
|
||||
...defaultPollOptions,
|
||||
streamLog: true
|
||||
})
|
||||
|
||||
expect(getFileStream).not.toHaveBeenCalled()
|
||||
expect(saveLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not attempt to fetch and save the log after each poll when streamLog is false', async () => {
|
||||
mockSimplePoll()
|
||||
|
||||
await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(saveLogModule.saveLog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return the current status when the max poll count is reached', async () => {
|
||||
mockRunningPoll()
|
||||
|
||||
const state = await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
{
|
||||
...defaultPollOptions,
|
||||
maxPollCount: 1
|
||||
}
|
||||
)
|
||||
|
||||
expect(state).toEqual('running')
|
||||
})
|
||||
|
||||
it('should poll with a larger interval for longer running jobs', async () => {
|
||||
mockLongPoll()
|
||||
|
||||
const state = await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
{
|
||||
...defaultPollOptions,
|
||||
maxPollCount: 200,
|
||||
pollInterval: 10
|
||||
}
|
||||
)
|
||||
|
||||
expect(state).toEqual('completed')
|
||||
}, 200000)
|
||||
|
||||
it('should continue polling until the job completes or errors', async () => {
|
||||
mockSimplePoll(1)
|
||||
|
||||
const state = await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
||||
expect(state).toEqual('completed')
|
||||
})
|
||||
|
||||
it('should print the state to the console when debug is on', async () => {
|
||||
jest.spyOn((process as any).logger, 'info')
|
||||
mockSimplePoll()
|
||||
|
||||
await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
true,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect((process as any).logger.info).toHaveBeenCalledTimes(4)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Polling job status...'
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Current job state: running'
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'Polling job status...'
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'Current job state: completed'
|
||||
)
|
||||
})
|
||||
|
||||
it('should continue polling when there is a single error in between', async () => {
|
||||
mockPollWithSingleError()
|
||||
|
||||
const state = await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
)
|
||||
|
||||
expect(requestClient.get).toHaveBeenCalledTimes(2)
|
||||
expect(state).toEqual('completed')
|
||||
})
|
||||
|
||||
it('should throw an error when the error count exceeds the set value of 5', async () => {
|
||||
mockErroredPoll()
|
||||
|
||||
const error = await pollJobState(
|
||||
requestClient,
|
||||
mockJob,
|
||||
false,
|
||||
undefined,
|
||||
defaultPollOptions
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error.message).toEqual(
|
||||
'Error while polling job state for job j0b: Status Error'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../../request/RequestClient')
|
||||
jest.mock('../../../auth/getTokens')
|
||||
jest.mock('../saveLog')
|
||||
jest.mock('../getFileStream')
|
||||
jest.mock('../../../utils/isNode')
|
||||
|
||||
jest
|
||||
.spyOn(requestClient, 'get')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: 'completed', etag: '', status: 200 })
|
||||
)
|
||||
jest
|
||||
.spyOn(getTokensModule, 'getTokens')
|
||||
.mockImplementation(() => Promise.resolve(mockAuthConfig))
|
||||
jest
|
||||
.spyOn(saveLogModule, 'saveLog')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
jest
|
||||
.spyOn(getFileStreamModule, 'getFileStream')
|
||||
.mockImplementation(() => Promise.resolve({} as unknown as WriteStream))
|
||||
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => true)
|
||||
}
|
||||
|
||||
const mockSimplePoll = (runningCount = 2) => {
|
||||
let count = 0
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||
count++
|
||||
if (url.includes('job')) {
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
}
|
||||
return Promise.resolve({
|
||||
result:
|
||||
count === 0
|
||||
? 'pending'
|
||||
: count <= runningCount
|
||||
? 'running'
|
||||
: 'completed',
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mockRunningPoll = () => {
|
||||
let count = 0
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||
count++
|
||||
if (url.includes('job')) {
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
}
|
||||
return Promise.resolve({
|
||||
result: count === 0 ? 'pending' : 'running',
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mockLongPoll = () => {
|
||||
let count = 0
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||
count++
|
||||
if (url.includes('job')) {
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
}
|
||||
return Promise.resolve({
|
||||
result: count <= 102 ? 'running' : 'completed',
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mockPollWithSingleError = () => {
|
||||
let count = 0
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||
count++
|
||||
if (url.includes('job')) {
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
}
|
||||
if (count === 1) {
|
||||
return Promise.reject('Status Error')
|
||||
}
|
||||
return Promise.resolve({
|
||||
result: count === 0 ? 'pending' : 'completed',
|
||||
etag: '',
|
||||
status: 200
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const mockErroredPoll = () => {
|
||||
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
|
||||
if (url.includes('job')) {
|
||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||
}
|
||||
return Promise.reject('Status Error')
|
||||
})
|
||||
}
|
||||
73
src/api/viya/spec/saveLog.spec.ts
Normal file
73
src/api/viya/spec/saveLog.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Logger, LogLevel } from '@sasjs/utils'
|
||||
import { RequestClient } from '../../../request/RequestClient'
|
||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||
import * as writeStreamModule from '../writeStream'
|
||||
import { saveLog } from '../saveLog'
|
||||
import { mockJob } from './mockResponses'
|
||||
import { WriteStream } from '../../../types'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
const stream = {} as unknown as WriteStream
|
||||
|
||||
describe('saveLog', () => {
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
it('should throw an error when a valid access token is not provided', async () => {
|
||||
const error = await saveLog(mockJob, requestClient, 0, 100, stream).catch(
|
||||
(e) => e
|
||||
)
|
||||
|
||||
expect(error.message).toContain(
|
||||
`Logs for job ${mockJob.id} cannot be fetched without a valid access token.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error when the log URL is not available', async () => {
|
||||
const error = await saveLog(
|
||||
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'log') },
|
||||
requestClient,
|
||||
0,
|
||||
100,
|
||||
stream,
|
||||
't0k3n'
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
`Log URL for job ${mockJob.id} was not found.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should fetch and save logs to the given path', async () => {
|
||||
await saveLog(mockJob, requestClient, 0, 100, stream, 't0k3n')
|
||||
|
||||
expect(fetchLogsModule.fetchLog).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
't0k3n',
|
||||
'/log/content',
|
||||
0,
|
||||
100
|
||||
)
|
||||
expect(writeStreamModule.writeStream).toHaveBeenCalledWith(
|
||||
stream,
|
||||
'Test Log'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../../request/RequestClient')
|
||||
jest.mock('../../../utils/fetchLogByChunks')
|
||||
jest.mock('@sasjs/utils')
|
||||
jest.mock('../writeStream')
|
||||
|
||||
jest
|
||||
.spyOn(fetchLogsModule, 'fetchLog')
|
||||
.mockImplementation(() => Promise.resolve('Test Log'))
|
||||
jest
|
||||
.spyOn(writeStreamModule, 'writeStream')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
}
|
||||
67
src/api/viya/spec/uploadTables.spec.ts
Normal file
67
src/api/viya/spec/uploadTables.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { RequestClient } from '../../../request/RequestClient'
|
||||
import * as convertToCsvModule from '../../../utils/convertToCsv'
|
||||
import { uploadTables } from '../uploadTables'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('uploadTables', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks()
|
||||
})
|
||||
|
||||
it('should return a list of uploaded files', async () => {
|
||||
const data = { foo: 'bar' }
|
||||
|
||||
const files = await uploadTables(requestClient, data, 't0k3n')
|
||||
|
||||
expect(files).toEqual([{ tableName: 'foo', file: 'test-file' }])
|
||||
expect(requestClient.uploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(requestClient.uploadFile).toHaveBeenCalledWith(
|
||||
'/files/files#rawUpload',
|
||||
'Test CSV',
|
||||
't0k3n'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error when the CSV exceeds the maximum length', async () => {
|
||||
const data = { foo: 'bar' }
|
||||
jest
|
||||
.spyOn(convertToCsvModule, 'convertToCSV')
|
||||
.mockImplementation(() => 'ERROR: LARGE STRING LENGTH')
|
||||
|
||||
const error = await uploadTables(requestClient, data, 't0k3n').catch(
|
||||
(e) => e
|
||||
)
|
||||
|
||||
expect(requestClient.uploadFile).not.toHaveBeenCalled()
|
||||
expect(error.message).toEqual(
|
||||
'The max length of a string value in SASjs is 32765 characters.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error when the file upload fails', async () => {
|
||||
const data = { foo: 'bar' }
|
||||
jest
|
||||
.spyOn(requestClient, 'uploadFile')
|
||||
.mockImplementation(() => Promise.reject('Upload Error'))
|
||||
|
||||
const error = await uploadTables(requestClient, data, 't0k3n').catch(
|
||||
(e) => e
|
||||
)
|
||||
|
||||
expect(error).toContain('Error while uploading file.')
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../../utils/convertToCsv')
|
||||
jest
|
||||
.spyOn(convertToCsvModule, 'convertToCSV')
|
||||
.mockImplementation(() => 'Test CSV')
|
||||
jest
|
||||
.spyOn(requestClient, 'uploadFile')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: 'test-file', etag: '' })
|
||||
)
|
||||
}
|
||||
25
src/api/viya/spec/writeStream.spec.ts
Normal file
25
src/api/viya/spec/writeStream.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { WriteStream } from '../../../types'
|
||||
import { writeStream } from '../writeStream'
|
||||
import 'jest-extended'
|
||||
|
||||
describe('writeStream', () => {
|
||||
const stream: WriteStream = {
|
||||
write: jest.fn(),
|
||||
path: 'test'
|
||||
}
|
||||
|
||||
it('should resolve when the stream is written successfully', async () => {
|
||||
expect(writeStream(stream, 'test')).toResolve()
|
||||
|
||||
expect(stream.write).toHaveBeenCalledWith('test\n', expect.anything())
|
||||
})
|
||||
|
||||
it('should reject when the write errors out', async () => {
|
||||
jest
|
||||
.spyOn(stream, 'write')
|
||||
.mockImplementation((_, callback) => callback(new Error('Test Error')))
|
||||
const error = await writeStream(stream, 'test').catch((e) => e)
|
||||
|
||||
expect(error.message).toEqual('Test Error')
|
||||
})
|
||||
})
|
||||
37
src/api/viya/uploadTables.ts
Normal file
37
src/api/viya/uploadTables.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { convertToCSV } from '../../utils/convertToCsv'
|
||||
|
||||
/**
|
||||
* Uploads tables to SAS as specially formatted CSVs.
|
||||
* This is more compact than JSON, and easier to read within SAS.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param data - the JSON representation of the data to be uploaded
|
||||
* @param accessToken - an optional access token for authentication/authorization
|
||||
* The access token is not required when uploading tables from the browser.
|
||||
*/
|
||||
export async function uploadTables(
|
||||
requestClient: RequestClient,
|
||||
data: any,
|
||||
accessToken?: string
|
||||
) {
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const tableName in data) {
|
||||
const csv = convertToCSV(data[tableName])
|
||||
if (csv === 'ERROR: LARGE STRING LENGTH') {
|
||||
throw new Error(
|
||||
'The max length of a string value in SASjs is 32765 characters.'
|
||||
)
|
||||
}
|
||||
|
||||
const uploadResponse = await requestClient
|
||||
.uploadFile(`/files/files#rawUpload`, csv, accessToken)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while uploading file. ')
|
||||
})
|
||||
|
||||
uploadedFiles.push({ tableName, file: uploadResponse.result })
|
||||
}
|
||||
return uploadedFiles
|
||||
}
|
||||
15
src/api/viya/writeStream.ts
Normal file
15
src/api/viya/writeStream.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WriteStream } from '../../types'
|
||||
|
||||
export const writeStream = async (
|
||||
stream: WriteStream,
|
||||
content: string
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(content + '\n', (e) => {
|
||||
if (e) {
|
||||
return reject(e)
|
||||
}
|
||||
return resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,26 +236,27 @@ 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
|
||||
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('')
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
isLoggedIn,
|
||||
userName: this.userName,
|
||||
loginForm
|
||||
})
|
||||
}
|
||||
|
||||
private getLoginForm(response: any) {
|
||||
|
||||
53
src/auth/getAccessToken.ts
Normal file
53
src/auth/getAccessToken.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
|
||||
/**
|
||||
* Exchanges the auth code for an access token for the given client.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param clientId - the client ID to authenticate with.
|
||||
* @param clientSecret - the client secret to authenticate with.
|
||||
* @param authCode - the auth code received from the server.
|
||||
*/
|
||||
export async function getAccessToken(
|
||||
requestClient: RequestClient,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
authCode: string
|
||||
): Promise<SasAuthResponse> {
|
||||
const url = '/SASLogon/oauth/token'
|
||||
let token
|
||||
if (typeof Buffer === 'undefined') {
|
||||
token = btoa(clientId + ':' + clientSecret)
|
||||
} else {
|
||||
token = Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
}
|
||||
const headers = {
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
|
||||
let formData
|
||||
if (typeof FormData === 'undefined') {
|
||||
formData = new NodeFormData()
|
||||
} else {
|
||||
formData = new FormData()
|
||||
}
|
||||
formData.append('grant_type', 'authorization_code')
|
||||
formData.append('code', authCode)
|
||||
|
||||
const authResponse = await requestClient
|
||||
.post(
|
||||
url,
|
||||
formData,
|
||||
undefined,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
.then((res) => res.result as SasAuthResponse)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while getting access token')
|
||||
})
|
||||
|
||||
return authResponse
|
||||
}
|
||||
40
src/auth/getTokens.ts
Normal file
40
src/auth/getTokens.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
isAccessTokenExpiring,
|
||||
isRefreshTokenExpiring,
|
||||
hasTokenExpired
|
||||
} from '@sasjs/utils/auth'
|
||||
import { AuthConfig } from '@sasjs/utils/types'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import { refreshTokens } from './refreshTokens'
|
||||
|
||||
/**
|
||||
* Returns the auth configuration, refreshing the tokens if necessary.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param authConfig - an object containing a client ID, secret, access token and refresh token
|
||||
*/
|
||||
export async function getTokens(
|
||||
requestClient: RequestClient,
|
||||
authConfig: AuthConfig
|
||||
): Promise<AuthConfig> {
|
||||
const logger = process.logger || console
|
||||
let { access_token, refresh_token, client, secret } = authConfig
|
||||
if (
|
||||
isAccessTokenExpiring(access_token) ||
|
||||
isRefreshTokenExpiring(refresh_token)
|
||||
) {
|
||||
if (hasTokenExpired(refresh_token)) {
|
||||
const error =
|
||||
'Unable to obtain new access token. Your refresh token has expired.'
|
||||
logger.error(error)
|
||||
throw new Error(error)
|
||||
}
|
||||
logger.info('Refreshing access and refresh tokens.')
|
||||
;({ access_token, refresh_token } = await refreshTokens(
|
||||
requestClient,
|
||||
client,
|
||||
secret,
|
||||
refresh_token
|
||||
))
|
||||
}
|
||||
return { access_token, refresh_token, client, secret }
|
||||
}
|
||||
40
src/auth/openWebPage.ts
Normal file
40
src/auth/openWebPage.ts
Normal 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
|
||||
}
|
||||
49
src/auth/refreshTokens.ts
Normal file
49
src/auth/refreshTokens.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
|
||||
/**
|
||||
* Exchanges the refresh token for an access token for the given client.
|
||||
* @param requestClient - the pre-configured HTTP request client
|
||||
* @param clientId - the client ID to authenticate with.
|
||||
* @param clientSecret - the client secret to authenticate with.
|
||||
* @param authCode - the refresh token received from the server.
|
||||
*/
|
||||
export async function refreshTokens(
|
||||
requestClient: RequestClient,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
refreshToken: string
|
||||
) {
|
||||
const url = '/SASLogon/oauth/token'
|
||||
let token
|
||||
token =
|
||||
typeof Buffer === 'undefined'
|
||||
? btoa(clientId + ':' + clientSecret)
|
||||
: Buffer.from(clientId + ':' + clientSecret).toString('base64')
|
||||
|
||||
const headers = {
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
|
||||
const formData =
|
||||
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
|
||||
formData.append('grant_type', 'refresh_token')
|
||||
formData.append('refresh_token', refreshToken)
|
||||
|
||||
const authResponse = await requestClient
|
||||
.post<SasAuthResponse>(
|
||||
url,
|
||||
formData,
|
||||
undefined,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
.then((res) => res.result)
|
||||
.catch((err) => {
|
||||
throw prefixMessage(err, 'Error while refreshing tokens')
|
||||
})
|
||||
|
||||
return authResponse
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
75
src/auth/spec/getAccessToken.spec.ts
Normal file
75
src/auth/spec/getAccessToken.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { AuthConfig } from '@sasjs/utils'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { getAccessToken } from '../getAccessToken'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should attempt to refresh tokens', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(30)
|
||||
const refresh_token = generateToken(30)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: mockAuthResponse, etag: '' })
|
||||
)
|
||||
const token = Buffer.from(
|
||||
authConfig.client + ':' + authConfig.secret
|
||||
).toString('base64')
|
||||
|
||||
await getAccessToken(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
)
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
'/SASLogon/oauth/token',
|
||||
expect.any(NodeFormData),
|
||||
undefined,
|
||||
expect.stringContaining('multipart/form-data; boundary='),
|
||||
{
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors while refreshing tokens', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(30)
|
||||
const refresh_token = generateToken(30)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() => Promise.reject('Token Error'))
|
||||
|
||||
const error = await getAccessToken(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while getting access token')
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../request/RequestClient')
|
||||
}
|
||||
79
src/auth/spec/getTokens.spec.ts
Normal file
79
src/auth/spec/getTokens.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { AuthConfig } from '@sasjs/utils'
|
||||
import * as refreshTokensModule from '../refreshTokens'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { getTokens } from '../getTokens'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('getTokens', () => {
|
||||
it('should attempt to refresh tokens if the access token is expiring', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(30)
|
||||
const refresh_token = generateToken(86400000)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
|
||||
await getTokens(requestClient, authConfig)
|
||||
|
||||
expect(refreshTokensModule.refreshTokens).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should attempt to refresh tokens if the refresh token is expiring', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(86400000)
|
||||
const refresh_token = generateToken(30)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
|
||||
await getTokens(requestClient, authConfig)
|
||||
|
||||
expect(refreshTokensModule.refreshTokens).toHaveBeenCalledWith(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error if the refresh token has already expired', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(86400000)
|
||||
const refresh_token = generateToken(-36000)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
const expectedError =
|
||||
'Unable to obtain new access token. Your refresh token has expired.'
|
||||
|
||||
const error = await getTokens(requestClient, authConfig).catch((e) => e)
|
||||
|
||||
expect(error.message).toEqual(expectedError)
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../request/RequestClient')
|
||||
jest.mock('../refreshTokens')
|
||||
|
||||
jest
|
||||
.spyOn(refreshTokensModule, 'refreshTokens')
|
||||
.mockImplementation(() => Promise.resolve(mockAuthResponse))
|
||||
}
|
||||
@@ -1,2 +1,49 @@
|
||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
||||
|
||||
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
|
||||
export const mockLoginSuccessResponse = `You have signed in`
|
||||
|
||||
export const mockAuthResponse: SasAuthResponse = {
|
||||
access_token: 'acc355',
|
||||
refresh_token: 'r3fr35h',
|
||||
id_token: 'id',
|
||||
token_type: 'bearer',
|
||||
expires_in: new Date().valueOf(),
|
||||
scope: 'default',
|
||||
jti: 'test'
|
||||
}
|
||||
|
||||
export const generateToken = (timeToLiveSeconds: number): string => {
|
||||
const exp =
|
||||
new Date(new Date().getTime() + timeToLiveSeconds * 1000).getTime() / 1000
|
||||
const header = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9'
|
||||
const payload = Buffer.from(JSON.stringify({ exp })).toString('base64')
|
||||
const signature = '4-iaDojEVl0pJQMjrbM1EzUIfAZgsbK_kgnVyVxFSVo'
|
||||
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
|
||||
})
|
||||
|
||||
64
src/auth/spec/openWebPage.spec.ts
Normal file
64
src/auth/spec/openWebPage.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
75
src/auth/spec/refreshTokens.spec.ts
Normal file
75
src/auth/spec/refreshTokens.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { AuthConfig } from '@sasjs/utils'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { refreshTokens } from '../refreshTokens'
|
||||
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('refreshTokens', () => {
|
||||
it('should attempt to refresh tokens', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(30)
|
||||
const refresh_token = generateToken(30)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ result: mockAuthResponse, etag: '' })
|
||||
)
|
||||
const token = Buffer.from(
|
||||
authConfig.client + ':' + authConfig.secret
|
||||
).toString('base64')
|
||||
|
||||
await refreshTokens(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
)
|
||||
|
||||
expect(requestClient.post).toHaveBeenCalledWith(
|
||||
'/SASLogon/oauth/token',
|
||||
expect.any(NodeFormData),
|
||||
undefined,
|
||||
expect.stringContaining('multipart/form-data; boundary='),
|
||||
{
|
||||
Authorization: 'Basic ' + token
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle errors while refreshing tokens', async () => {
|
||||
setupMocks()
|
||||
const access_token = generateToken(30)
|
||||
const refresh_token = generateToken(30)
|
||||
const authConfig: AuthConfig = {
|
||||
access_token,
|
||||
refresh_token,
|
||||
client: 'cl13nt',
|
||||
secret: 's3cr3t'
|
||||
}
|
||||
jest
|
||||
.spyOn(requestClient, 'post')
|
||||
.mockImplementation(() => Promise.reject('Token Error'))
|
||||
|
||||
const error = await refreshTokens(
|
||||
requestClient,
|
||||
authConfig.client,
|
||||
authConfig.secret,
|
||||
authConfig.refresh_token
|
||||
).catch((e) => e)
|
||||
|
||||
expect(error).toContain('Error while refreshing tokens')
|
||||
})
|
||||
})
|
||||
|
||||
const setupMocks = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../request/RequestClient')
|
||||
}
|
||||
37
src/auth/spec/verifySas9Login.spec.ts
Normal file
37
src/auth/spec/verifySas9Login.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
38
src/auth/spec/verifySasViyaLogin.spec.ts
Normal file
38
src/auth/spec/verifySasViyaLogin.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
20
src/auth/verifySas9Login.ts
Normal file
20
src/auth/verifySas9Login.ts
Normal 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 }
|
||||
}
|
||||
33
src/auth/verifySasViyaLogin.ts
Normal file
33
src/auth/verifySasViyaLogin.ts
Normal 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')
|
||||
@@ -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.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)
|
||||
})
|
||||
|
||||
@@ -16,10 +16,11 @@ export class Sas9JobExecutor extends BaseJobExecutor {
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
serverType: ServerType,
|
||||
private jobsPath: string
|
||||
private jobsPath: string,
|
||||
allowInsecureRequests: boolean
|
||||
) {
|
||||
super(serverUrl, serverType)
|
||||
this.requestClient = new Sas9RequestClient(serverUrl, false)
|
||||
this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests)
|
||||
}
|
||||
|
||||
async execute(sasJob: string, data: any, config: any) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { ServerType } from '@sasjs/utils/types'
|
||||
import {
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes,
|
||||
ServerType
|
||||
} from '@sasjs/utils/types'
|
||||
import {
|
||||
ErrorResponse,
|
||||
JobExecutionError,
|
||||
@@ -8,7 +12,11 @@ import { generateFileUploadForm } from '../file/generateFileUploadForm'
|
||||
import { generateTableUploadForm } from '../file/generateTableUploadForm'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import { SASViyaApiClient } from '../SASViyaApiClient'
|
||||
import { isRelativePath, isValidJson } from '../utils'
|
||||
import {
|
||||
isRelativePath,
|
||||
parseSasViyaDebugResponse,
|
||||
appendExtraResponseAttributes
|
||||
} from '../utils'
|
||||
import { BaseJobExecutor } from './JobExecutor'
|
||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
||||
|
||||
@@ -32,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)
|
||||
@@ -43,14 +53,25 @@ 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)
|
||||
: ''
|
||||
const jobUri = await this.getJobUri(sasJob)
|
||||
|
||||
apiUrl += jobUri.length > 0 ? '&_job=' + jobUri : ''
|
||||
|
||||
apiUrl += config.contextName ? `&_contextname=${config.contextName}` : ''
|
||||
if (jobUri.length > 0) {
|
||||
apiUrl += '&_job=' + jobUri
|
||||
/**
|
||||
* Using both _job and _program parameters will cause a conflict in the JES web app, as it’s not clear whether or not the server should make the extra fetch for the job uri.
|
||||
* To handle this, we add the extra underscore and recreate the _program variable in the SAS side of the SASjs adapter so it remains available for backend developers.
|
||||
*/
|
||||
apiUrl = apiUrl.replace('_program=', '__program=')
|
||||
}
|
||||
|
||||
// if context name exists and is not blank string
|
||||
// then add _contextname variable in apiUrl
|
||||
apiUrl +=
|
||||
config.contextName && !/\s/.test(config.contextName)
|
||||
? `&_contextname=${config.contextName}`
|
||||
: ''
|
||||
}
|
||||
|
||||
let requestParams = {
|
||||
@@ -93,29 +114,34 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
|
||||
const requestPromise = new Promise((resolve, reject) => {
|
||||
this.requestClient!.post(apiUrl, formData, undefined)
|
||||
.then(async (res) => {
|
||||
if (this.serverType === ServerType.SasViya && config.debug) {
|
||||
const jsonResponse = await this.parseSasViyaDebugResponse(
|
||||
res.result as string
|
||||
)
|
||||
this.appendRequest(res, sasJob, config.debug)
|
||||
resolve(jsonResponse)
|
||||
}
|
||||
if (this.serverType === ServerType.Sas9 && config.debug) {
|
||||
const jsonResponse = parseWeboutResponse(res.result as string)
|
||||
if (jsonResponse === '') {
|
||||
throw new Error(
|
||||
'Valid JSON could not be extracted from response.'
|
||||
)
|
||||
}
|
||||
.then(async (res: any) => {
|
||||
let jsonResponse = res.result
|
||||
|
||||
isValidJson(jsonResponse)
|
||||
this.appendRequest(res, sasJob, config.debug)
|
||||
resolve(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
|
||||
}
|
||||
}
|
||||
isValidJson(res.result as string)
|
||||
|
||||
this.appendRequest(res, sasJob, config.debug)
|
||||
resolve(res.result)
|
||||
|
||||
const responseObject = appendExtraResponseAttributes(
|
||||
{ result: jsonResponse },
|
||||
extraResponseAttributes
|
||||
)
|
||||
resolve(responseObject)
|
||||
})
|
||||
.catch(async (e: Error) => {
|
||||
if (e instanceof JobExecutionError) {
|
||||
@@ -125,14 +151,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)
|
||||
@@ -142,6 +168,8 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
await loginCallback()
|
||||
} else {
|
||||
reject(new ErrorResponse(e?.message, e))
|
||||
}
|
||||
@@ -151,20 +179,6 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
private parseSasViyaDebugResponse = async (response: string) => {
|
||||
const iframeStart = response.split(
|
||||
'<iframe style="width: 99%; height: 500px" src="'
|
||||
)[1]
|
||||
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
|
||||
if (!jsonUrl) {
|
||||
throw new Error('Unable to find webout file URL.')
|
||||
}
|
||||
|
||||
return this.requestClient
|
||||
.get(this.serverUrl + jsonUrl, undefined)
|
||||
.then((res) => res.result)
|
||||
}
|
||||
|
||||
private async getJobUri(sasJob: string) {
|
||||
if (!this.sasViyaApiClient) return ''
|
||||
let uri = ''
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
|
||||
import { isValidJson } from '../utils'
|
||||
import { getValidJson } from '../utils'
|
||||
|
||||
export interface HttpClient {
|
||||
get<T>(
|
||||
@@ -43,6 +43,7 @@ export interface HttpClient {
|
||||
|
||||
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
|
||||
clearCsrfTokens(): void
|
||||
getBaseUrl(): string
|
||||
}
|
||||
|
||||
export class RequestClient implements HttpClient {
|
||||
@@ -78,6 +79,10 @@ export class RequestClient implements HttpClient {
|
||||
this.fileUploadCsrfToken = { headerName: '', value: '' }
|
||||
}
|
||||
|
||||
public getBaseUrl() {
|
||||
return this.httpClient.defaults.baseURL || ''
|
||||
}
|
||||
|
||||
public async get<T>(
|
||||
url: string,
|
||||
accessToken: string | undefined,
|
||||
@@ -424,13 +429,7 @@ export class RequestClient implements HttpClient {
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const weboutResponse = parseWeboutResponse(response.data)
|
||||
if (weboutResponse === '') {
|
||||
throw new Error('Valid JSON could not be extracted from response.')
|
||||
}
|
||||
|
||||
const jsonResponse = isValidJson(weboutResponse)
|
||||
parsedResponse = jsonResponse
|
||||
parsedResponse = JSON.parse(parseWeboutResponse(response.data))
|
||||
} catch {
|
||||
parsedResponse = response.data
|
||||
}
|
||||
@@ -500,46 +499,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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { RequestClient } from '../request/RequestClient'
|
||||
import { NoSessionStateError } from '../types/errors'
|
||||
import * as dotenv from 'dotenv'
|
||||
import axios from 'axios'
|
||||
import { Logger, LogLevel } from '@sasjs/utils'
|
||||
import { Session } from '../types'
|
||||
|
||||
jest.mock('axios')
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||
@@ -47,36 +49,91 @@ describe('SessionManager', () => {
|
||||
})
|
||||
|
||||
describe('waitForSession', () => {
|
||||
const session: Session = {
|
||||
id: 'id',
|
||||
state: '',
|
||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 0
|
||||
},
|
||||
creationTimeStamp: ''
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(process as any).logger = new Logger(LogLevel.Off)
|
||||
})
|
||||
|
||||
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => {
|
||||
const responseStatus = 304
|
||||
let requestAttempt = 0
|
||||
const requestAttemptLimit = 10
|
||||
const sessionState = 'idle'
|
||||
|
||||
mockedAxios.get.mockImplementation(() => {
|
||||
requestAttempt += 1
|
||||
|
||||
if (requestAttempt >= requestAttemptLimit) {
|
||||
return Promise.resolve({ data: sessionState, status: 200 })
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: '', status: 304 })
|
||||
})
|
||||
|
||||
jest.spyOn((process as any).logger, 'info')
|
||||
|
||||
sessionManager.debug = true
|
||||
|
||||
await expect(
|
||||
sessionManager['waitForSession'](session, null, 'access_token')
|
||||
).resolves.toEqual(sessionState)
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
||||
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Polling session status...'
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}`
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
`Current session state is '${sessionState}'`
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error if there is no session link', async () => {
|
||||
const customSession = JSON.parse(JSON.stringify(session))
|
||||
customSession.links = []
|
||||
|
||||
mockedAxios.get.mockImplementation(() =>
|
||||
Promise.resolve({ data: '', status: responseStatus })
|
||||
Promise.resolve({ data: customSession.state, status: 200 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
sessionManager['waitForSession'](
|
||||
{
|
||||
id: 'id',
|
||||
state: '',
|
||||
links: [
|
||||
{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }
|
||||
],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 0
|
||||
},
|
||||
creationTimeStamp: ''
|
||||
},
|
||||
null,
|
||||
'access_token'
|
||||
)
|
||||
).rejects.toEqual(
|
||||
new NoSessionStateError(
|
||||
responseStatus,
|
||||
process.env.SERVER_URL as string,
|
||||
'logUrl'
|
||||
)
|
||||
sessionManager['waitForSession'](customSession, null, 'access_token')
|
||||
).rejects.toContain('Error while getting session state link.')
|
||||
})
|
||||
|
||||
it('should throw an error if could not get session state', async () => {
|
||||
mockedAxios.get.mockImplementation(() => Promise.reject('Mocked error'))
|
||||
|
||||
await expect(
|
||||
sessionManager['waitForSession'](session, null, 'access_token')
|
||||
).rejects.toContain('Error while getting session state.')
|
||||
})
|
||||
|
||||
it('should return session state', async () => {
|
||||
const customSession = JSON.parse(JSON.stringify(session))
|
||||
customSession.state = 'completed'
|
||||
|
||||
mockedAxios.get.mockImplementation(() =>
|
||||
Promise.resolve({ data: customSession.state, status: 200 })
|
||||
)
|
||||
|
||||
await expect(
|
||||
sessionManager['waitForSession'](customSession, null, 'access_token')
|
||||
).resolves.toEqual(customSession.state)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
36
src/test/utils/getValidJson.spec.ts
Normal file
36
src/test/utils/getValidJson.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getValidJson } from '../../utils'
|
||||
import { JsonParseArrayError, InvalidJsonError } from '../../types/errors'
|
||||
|
||||
describe('jsonValidator', () => {
|
||||
it('should not throw an error with a valid json', () => {
|
||||
const json = {
|
||||
test: 'test'
|
||||
}
|
||||
|
||||
expect(getValidJson(json)).toBe(json)
|
||||
})
|
||||
|
||||
it('should not throw an error with a valid json string', () => {
|
||||
const json = {
|
||||
test: 'test'
|
||||
}
|
||||
|
||||
expect(getValidJson(JSON.stringify(json))).toStrictEqual(json)
|
||||
})
|
||||
|
||||
it('should throw an error with an invalid json', () => {
|
||||
const json = `{\"test\":\"test\"\"test2\":\"test\"}`
|
||||
const test = () => {
|
||||
getValidJson(json)
|
||||
}
|
||||
expect(test).toThrowError(InvalidJsonError)
|
||||
})
|
||||
|
||||
it('should throw an error when an array is passed', () => {
|
||||
const array = ['hello', 'world']
|
||||
const test = () => {
|
||||
getValidJson(array)
|
||||
}
|
||||
expect(test).toThrow(JsonParseArrayError)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { isValidJson } from '../../utils'
|
||||
|
||||
describe('jsonValidator', () => {
|
||||
it('should not throw an error with an valid json', () => {
|
||||
const json = {
|
||||
test: 'test'
|
||||
}
|
||||
|
||||
expect(isValidJson(json)).toBe(json)
|
||||
})
|
||||
|
||||
it('should not throw an error with an valid json string', () => {
|
||||
const json = {
|
||||
test: 'test'
|
||||
}
|
||||
|
||||
expect(isValidJson(JSON.stringify(json))).toStrictEqual(json)
|
||||
})
|
||||
|
||||
it('should throw an error with an invalid json', () => {
|
||||
const json = `{\"test\":\"test\"\"test2\":\"test\"}`
|
||||
|
||||
expect(() => {
|
||||
try {
|
||||
isValidJson(json)
|
||||
} catch (err) {
|
||||
throw new Error()
|
||||
}
|
||||
}).toThrowError
|
||||
})
|
||||
})
|
||||
8
src/types/Login.ts
Normal file
8
src/types/Login.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface LoginOptions {
|
||||
onLoggedOut?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
isLoggedIn: boolean
|
||||
userName: string
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface PollOptions {
|
||||
MAX_POLL_COUNT?: number
|
||||
POLL_INTERVAL?: number
|
||||
maxPollCount: number
|
||||
pollInterval: number
|
||||
streamLog: boolean
|
||||
logFolderPath?: string
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
4
src/types/WriteStream.ts
Normal file
4
src/types/WriteStream.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface WriteStream {
|
||||
write: (content: string, callback: (err?: Error) => any) => void
|
||||
path: string
|
||||
}
|
||||
7
src/types/errors/InvalidJsonError.ts
Normal file
7
src/types/errors/InvalidJsonError.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class InvalidJsonError extends Error {
|
||||
constructor() {
|
||||
super('Error: invalid Json string')
|
||||
this.name = 'InvalidJsonError'
|
||||
Object.setPrototypeOf(this, InvalidJsonError.prototype)
|
||||
}
|
||||
}
|
||||
11
src/types/errors/JobStatePollError.ts
Normal file
11
src/types/errors/JobStatePollError.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class JobStatePollError extends Error {
|
||||
constructor(id: string, public originalError: Error) {
|
||||
super(
|
||||
`Error while polling job state for job ${id}: ${
|
||||
originalError.message || originalError
|
||||
}`
|
||||
)
|
||||
this.name = 'JobStatePollError'
|
||||
Object.setPrototypeOf(this, JobStatePollError.prototype)
|
||||
}
|
||||
}
|
||||
7
src/types/errors/JsonParseArrayError.ts
Normal file
7
src/types/errors/JsonParseArrayError.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class JsonParseArrayError extends Error {
|
||||
constructor() {
|
||||
super('Can not parse array object to json.')
|
||||
this.name = 'JsonParseArrayError'
|
||||
Object.setPrototypeOf(this, JsonParseArrayError.prototype)
|
||||
}
|
||||
}
|
||||
40
src/types/errors/RootFolderNotFoundError.spec.ts
Normal file
40
src/types/errors/RootFolderNotFoundError.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { RootFolderNotFoundError } from './RootFolderNotFoundError'
|
||||
|
||||
describe('RootFolderNotFoundError', () => {
|
||||
it('when access token is provided, error message should contain the scopes in the token', () => {
|
||||
const token =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJzY29wZS0xIiwic2NvcGUtMiJdfQ.ktqPL2ulln-8Asa2jSV9QCfDYmQuNk4tNKopxJR5xZs'
|
||||
|
||||
const error = new RootFolderNotFoundError(
|
||||
'/myProject',
|
||||
'https://analytium.co.uk',
|
||||
token
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||
expect(error.message).toContain('scope-1')
|
||||
expect(error.message).toContain('scope-2')
|
||||
})
|
||||
|
||||
it('when access token is not provided, error message should not contain scopes', () => {
|
||||
const error = new RootFolderNotFoundError(
|
||||
'/myProject',
|
||||
'https://analytium.co.uk'
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||
expect(error.message).not.toContain(
|
||||
'Your access token contains the following scopes'
|
||||
)
|
||||
})
|
||||
|
||||
it('should include the folder path and SASDrive URL in the message', () => {
|
||||
const folderPath = '/myProject'
|
||||
const serverUrl = 'https://analytium.co.uk'
|
||||
const error = new RootFolderNotFoundError(folderPath, serverUrl)
|
||||
|
||||
expect(error).toBeInstanceOf(RootFolderNotFoundError)
|
||||
expect(error.message).toContain(folderPath)
|
||||
expect(error.message).toContain(`${serverUrl}/SASDrive`)
|
||||
})
|
||||
})
|
||||
24
src/types/errors/RootFolderNotFoundError.ts
Normal file
24
src/types/errors/RootFolderNotFoundError.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { decodeToken } from '@sasjs/utils/auth'
|
||||
|
||||
export class RootFolderNotFoundError extends Error {
|
||||
constructor(
|
||||
parentFolderPath: string,
|
||||
serverUrl: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
let message: string =
|
||||
`Root folder ${parentFolderPath} was not found.` +
|
||||
`\nPlease check ${serverUrl}/SASDrive.` +
|
||||
`\nIf the folder DOES exist then it is likely a permission problem.\n`
|
||||
if (accessToken) {
|
||||
const decodedToken = decodeToken(accessToken)
|
||||
let scope = decodedToken.scope
|
||||
scope = scope.map((element) => '* ' + element)
|
||||
message +=
|
||||
`Your access token contains the following scopes:\n` + scope.join('\n')
|
||||
}
|
||||
super(message)
|
||||
this.name = 'RootFolderNotFoundError'
|
||||
Object.setPrototypeOf(this, RootFolderNotFoundError.prototype)
|
||||
}
|
||||
}
|
||||
7
src/types/errors/WeboutResponseError.ts
Normal file
7
src/types/errors/WeboutResponseError.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class WeboutResponseError extends Error {
|
||||
constructor(public url: string) {
|
||||
super(`Error: error while parsing response from ${url}`)
|
||||
this.name = 'WeboutResponseError'
|
||||
Object.setPrototypeOf(this, WeboutResponseError.prototype)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ export * from './AuthorizeError'
|
||||
export * from './ComputeJobExecutionError'
|
||||
export * from './InternalServerError'
|
||||
export * from './JobExecutionError'
|
||||
export * from './JobStatePollError'
|
||||
export * from './LoginRequiredError'
|
||||
export * from './NotFoundError'
|
||||
export * from './ErrorResponse'
|
||||
export * from './NoSessionStateError'
|
||||
export * from './RootFolderNotFoundError'
|
||||
export * from './JsonParseArrayError'
|
||||
export * from './WeboutResponseError'
|
||||
export * from './InvalidJsonError'
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './SASjsRequest'
|
||||
export * from './Session'
|
||||
export * from './UploadFile'
|
||||
export * from './PollOptions'
|
||||
export * from './WriteStream'
|
||||
|
||||
22
src/utils/appendExtraResponseAttributes.ts
Normal file
22
src/utils/appendExtraResponseAttributes.ts
Normal 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
2
src/utils/delay.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -14,18 +14,36 @@ export const fetchLogByChunks = async (
|
||||
accessToken: string,
|
||||
logUrl: string,
|
||||
logCount: number
|
||||
): Promise<string> => {
|
||||
return await fetchLog(requestClient, accessToken, logUrl, 0, logCount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a section of the log file delineated by start and end lines
|
||||
* @param {object} requestClient - client object of Request Client.
|
||||
* @param {string} accessToken - an access token for an authorized user.
|
||||
* @param {string} logUrl - url of the log file.
|
||||
* @param {number} start - the line at which to start fetching the log.
|
||||
* @param {number} end - the line at which to stop fetching the log.
|
||||
* @returns an string containing log lines.
|
||||
*/
|
||||
export const fetchLog = async (
|
||||
requestClient: RequestClient,
|
||||
accessToken: string,
|
||||
logUrl: string,
|
||||
start: number,
|
||||
end: number
|
||||
): Promise<string> => {
|
||||
const logger = process.logger || console
|
||||
|
||||
let log: string = ''
|
||||
|
||||
const loglimit = logCount < 10000 ? logCount : 10000
|
||||
let start = 0
|
||||
const loglimit = end < 10000 ? end : 10000
|
||||
do {
|
||||
logger.info(
|
||||
`Fetching logs from line no: ${start + 1} to ${
|
||||
start + loglimit
|
||||
} of ${logCount}.`
|
||||
} of ${end}.`
|
||||
)
|
||||
const logChunkJson = await requestClient!
|
||||
.get<any>(`${logUrl}?start=${start}&limit=${loglimit}`, accessToken)
|
||||
@@ -40,6 +58,6 @@ export const fetchLogByChunks = async (
|
||||
log += logChunk
|
||||
|
||||
start += loglimit
|
||||
} while (start < logCount)
|
||||
} while (start < end)
|
||||
return log
|
||||
}
|
||||
|
||||
18
src/utils/getValidJson.ts
Normal file
18
src/utils/getValidJson.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { JsonParseArrayError, InvalidJsonError } from '../types/errors'
|
||||
|
||||
/**
|
||||
* if string passed then parse the string to json else if throw error for all other types unless it is not a valid json object.
|
||||
* @param str - string to check.
|
||||
*/
|
||||
export const getValidJson = (str: string | object) => {
|
||||
try {
|
||||
if (Array.isArray(str)) throw new JsonParseArrayError()
|
||||
|
||||
if (typeof str === 'object') return str
|
||||
|
||||
return JSON.parse(str)
|
||||
} catch (e) {
|
||||
if (e instanceof JsonParseArrayError) throw e
|
||||
throw new InvalidJsonError()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './asyncForEach'
|
||||
export * from './compareTimestamps'
|
||||
export * from './convertToCsv'
|
||||
export * from './delay'
|
||||
export * from './isNode'
|
||||
export * from './isRelativePath'
|
||||
export * from './isUri'
|
||||
export * from './isUrl'
|
||||
@@ -12,4 +14,6 @@ export * from './serialize'
|
||||
export * from './splitChunks'
|
||||
export * from './parseWeboutResponse'
|
||||
export * from './fetchLogByChunks'
|
||||
export * from './isValidJson'
|
||||
export * from './getValidJson'
|
||||
export * from './parseViyaDebugResponse'
|
||||
export * from './appendExtraResponseAttributes'
|
||||
|
||||
4
src/utils/isNode.ts
Normal file
4
src/utils/isNode.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const isNode = () =>
|
||||
typeof process !== 'undefined' &&
|
||||
process.versions != null &&
|
||||
process.versions.node != null
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Checks if string is in valid JSON format else throw error.
|
||||
* @param str - string to check.
|
||||
*/
|
||||
export const isValidJson = (str: string | object) => {
|
||||
try {
|
||||
if (typeof str === 'object') return str
|
||||
|
||||
return JSON.parse(str)
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JSON response.')
|
||||
}
|
||||
}
|
||||
172
src/utils/loginPrompt/index.ts
Normal file
172
src/utils/loginPrompt/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
enum domIDs {
|
||||
styles = 'sasjsAdapterStyles',
|
||||
overlay = 'sasjsAdapterLoginPromptBG',
|
||||
dialog = 'sasjsAdapterLoginPrompt'
|
||||
}
|
||||
const cssPrefix = 'sasjs-adapter'
|
||||
|
||||
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(`${cssPrefix}popUpBG`)
|
||||
|
||||
const loginPrompt = document.createElement('div')
|
||||
loginPrompt.id = domIDs.dialog
|
||||
loginPrompt.classList.add(`${cssPrefix}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 = `
|
||||
.${cssPrefix}popUpBG ,
|
||||
.${cssPrefix}popUp {
|
||||
z-index: 10000;
|
||||
}
|
||||
.${cssPrefix}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%);
|
||||
}
|
||||
.${cssPrefix}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;
|
||||
}
|
||||
.${cssPrefix}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;
|
||||
}
|
||||
.${cssPrefix}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;
|
||||
}
|
||||
.${cssPrefix}popUp .cancel {
|
||||
float: left;
|
||||
}
|
||||
.${cssPrefix}popUp .confirm {
|
||||
float: right;
|
||||
}
|
||||
.${cssPrefix}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);
|
||||
}
|
||||
.${cssPrefix}popUp > button:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.${cssPrefix}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);
|
||||
}
|
||||
`
|
||||
30
src/utils/parseViyaDebugResponse.ts
Normal file
30
src/utils/parseViyaDebugResponse.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import { getValidJson } from '../utils'
|
||||
|
||||
/**
|
||||
* When querying a Viya job using the Web approach (as opposed to using the APIs) with _DEBUG enabled,
|
||||
* the first response contains the log with the content in an iframe. Therefore when debug is enabled,
|
||||
* and the serverType is VIYA, and useComputeApi is null (WEB), we call this function to extract the
|
||||
* (_webout) content from the iframe.
|
||||
* @param response - first response from viya job
|
||||
* @param requestClient
|
||||
* @param serverUrl
|
||||
* @returns
|
||||
*/
|
||||
export const parseSasViyaDebugResponse = async (
|
||||
response: string,
|
||||
requestClient: RequestClient,
|
||||
serverUrl: string
|
||||
) => {
|
||||
const iframeStart = response.split(
|
||||
'<iframe style="width: 99%; height: 500px" src="'
|
||||
)[1]
|
||||
const jsonUrl = iframeStart ? iframeStart.split('"></iframe>')[0] : null
|
||||
if (!jsonUrl) {
|
||||
throw new Error('Unable to find webout file URL.')
|
||||
}
|
||||
|
||||
return requestClient
|
||||
.get(serverUrl + jsonUrl, undefined)
|
||||
.then((res: any) => getValidJson(res.result))
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export const parseWeboutResponse = (response: string) => {
|
||||
import { WeboutResponseError } from '../types/errors'
|
||||
|
||||
export const parseWeboutResponse = (response: string, url?: string) => {
|
||||
let sasResponse = ''
|
||||
|
||||
if (response.includes('>>weboutBEGIN<<')) {
|
||||
@@ -7,6 +9,7 @@ export const parseWeboutResponse = (response: string) => {
|
||||
.split('>>weboutBEGIN<<')[1]
|
||||
.split('>>weboutEND<<')[0]
|
||||
} catch (e) {
|
||||
if (url) throw new WeboutResponseError(url)
|
||||
sasResponse = ''
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user