mirror of
https://github.com/sasjs/adapter.git
synced 2026-01-03 18:50:05 +00:00
Compare commits
101 Commits
dependabot
...
v4.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde28046be | ||
|
|
eab61a80bf | ||
|
|
9149f932c3 | ||
|
|
fb30ff8876 | ||
|
|
afff422333 | ||
|
|
b49010cfe5 | ||
|
|
fd6fad9b07 | ||
|
|
8a10c229d6 | ||
|
|
66462fcc50 | ||
|
|
7e23b5db9d | ||
| 78f117812e | |||
|
|
55af8c3f50 | ||
| 1185c2f1bf | |||
|
|
2842636c4a | ||
| 8c7f614509 | |||
| 943f60ea11 | |||
| 3de343f135 | |||
| e11c97ec5d | |||
| 49fba07824 | |||
| b1c0e26c23 | |||
|
|
3ec73750b7 | ||
| e3c4cb6b90 | |||
| d35f1617b8 | |||
| 302752d79e | |||
| 4e1e3e8e77 | |||
| 954d3ff633 | |||
| fce0c7e522 | |||
| d0fbc7b8c7 | |||
| 6171199a7e | |||
| 4fb0b96f11 | |||
| 008a9b4ca5 | |||
| b3b2c1414c | |||
| 18be9e8806 | |||
| 7bdd826418 | |||
| 3713a226a4 | |||
| 77306fedee | |||
| be3ce56b85 | |||
| 851b8fce2a | |||
|
|
16dd175053 | ||
| 27698b3e8a | |||
| 0faa50685d | |||
|
|
0f20048fb4 | ||
| 249837dacf | |||
| a115c12f55 | |||
| 61c4d21467 | |||
| 3e9f38529f | |||
|
|
06f79307b9 | ||
|
|
5122d2a9c9 | ||
|
|
dc3eb3f0db | ||
|
|
b940bc7cc3 | ||
|
|
82fc55ac1c | ||
| fc1a22c8c5 | |||
| 57b9f86077 | |||
|
|
68f7b2eac2 | ||
|
|
2676873bb0 | ||
| add2f0a860 | |||
| 2072136577 | |||
| afae632fc6 | |||
|
|
317587a3c8 | ||
|
|
ffd6bc5a5c | ||
|
|
c2e64d9ba6 | ||
|
|
a90f699abd | ||
|
|
2cca192f88 | ||
|
|
053b07769a | ||
|
|
4c4511913c | ||
|
|
8c64c24f3c | ||
|
|
1f2f445002 | ||
|
|
6afa056a86 | ||
|
|
fe47ca1152 | ||
|
|
10da691f0f | ||
|
|
318f9694cb | ||
|
|
56e6131e5c | ||
| 5dfee30875 | |||
|
|
3a186bc55c | ||
|
|
0359fcb6be | ||
|
|
f2997169cb | ||
|
|
451f2dfaca | ||
|
|
38e11f1771 | ||
|
|
259b6b3ff2 | ||
|
|
5b2d9e675f | ||
|
|
8dd4ab8cec | ||
|
|
34135b889f | ||
|
|
62f4577b64 | ||
|
|
7a4feddd82 | ||
|
|
681abf5b3b | ||
|
|
46c6d3e7f4 | ||
|
|
5731b0f9b1 | ||
|
|
f18a523087 | ||
|
|
8cbd292f13 | ||
|
|
4851f25753 | ||
|
|
5756638dc2 | ||
|
|
e511cd613c | ||
|
|
2119c81ebb | ||
|
|
ea4b30d6ef | ||
|
|
f1e1b33571 | ||
|
|
ccb8599f00 | ||
|
|
5bcd17096b | ||
|
|
d744ee12a3 | ||
|
|
5f15226cd9 | ||
|
|
f31ea28b9c | ||
|
|
e315e4a619 |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -14,7 +14,7 @@ What code changes have been made to achieve the intent.
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
- [ ] Unit tests coverage has been increased and a new threshold is set.
|
||||||
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
|
- [ ] All `sasjs-cli` unit tests are passing (`npm test`).
|
||||||
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
|
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (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
|
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya
|
||||||
|
|||||||
8
.github/vpn/config.ovpn
vendored
8
.github/vpn/config.ovpn
vendored
@@ -3,10 +3,12 @@ client
|
|||||||
tls-client
|
tls-client
|
||||||
dev tun
|
dev tun
|
||||||
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
||||||
proto tcp
|
proto udp
|
||||||
remote vpn.4gl.io 7494
|
remote vpn.4gl.io 7194
|
||||||
resolv-retry infinite
|
resolv-retry infinite
|
||||||
cipher AES-256-CBC
|
# this will fallback from udp6 to udp4 as well
|
||||||
|
connect-timeout 5
|
||||||
|
data-ciphers AES-256-CBC:AES-256-GCM
|
||||||
auth SHA256
|
auth SHA256
|
||||||
script-security 2
|
script-security 2
|
||||||
keepalive 10 120
|
keepalive 10 120
|
||||||
|
|||||||
2
.github/workflows/assign-reviewer.yml
vendored
2
.github/workflows/assign-reviewer.yml
vendored
@@ -10,4 +10,4 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: uesteibar/reviewer-lottery@v1
|
- uses: uesteibar/reviewer-lottery@v1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GH_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
58
.github/workflows/build-unit-tests.yml
vendored
Normal file
58
.github/workflows/build-unit-tests.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||||
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||||
|
|
||||||
|
name: SASjs Build and Unit Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [lts/hydrogen]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
|
- name: Check npm audit
|
||||||
|
run: npm audit --production --audit-level=low
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Rimraf
|
||||||
|
run: npm i rimraf
|
||||||
|
|
||||||
|
- name: Check code style
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build Package
|
||||||
|
run: npm run package:lib
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
|
||||||
|
- name: Generate coverage report
|
||||||
|
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
15
.github/workflows/generateDocs.yml
vendored
15
.github/workflows/generateDocs.yml
vendored
@@ -21,7 +21,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -37,8 +46,8 @@ jobs:
|
|||||||
- name: Push generated docs
|
- name: Push generated docs
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GH_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
publish_branch: gh-pages
|
publish_branch: gh-pages
|
||||||
publish_dir: ./docs
|
publish_dir: ./docs
|
||||||
cname: adapter.sasjs.io
|
cname: adapter.sasjs.io
|
||||||
|
|
||||||
|
|||||||
13
.github/workflows/npmpublish.yml
vendored
13
.github/workflows/npmpublish.yml
vendored
@@ -22,7 +22,16 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
# 2. Restore npm cache manually
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -36,7 +45,7 @@ jobs:
|
|||||||
- name: Semantic Release
|
- name: Semantic Release
|
||||||
uses: cycjimmy/semantic-release-action@v3
|
uses: cycjimmy/semantic-release-action@v3
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
- name: Send Matrix message
|
- name: Send Matrix message
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
|
||||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||||
|
|
||||||
name: SASjs Build
|
name: SASjs Build and Server Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -20,19 +20,22 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Check npm audit
|
# 2. Restore npm cache manually
|
||||||
run: npm audit --production --audit-level=low
|
- name: Restore npm cache
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: npm-cache
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Check code style
|
- name: Install Rimraf
|
||||||
run: npm run lint
|
run: npm i rimraf
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build Package
|
- name: Build Package
|
||||||
run: npm run package:lib
|
run: npm run package:lib
|
||||||
@@ -52,6 +55,10 @@ jobs:
|
|||||||
USER_KEY: ${{ secrets.USER_KEY }}
|
USER_KEY: ${{ secrets.USER_KEY }}
|
||||||
TLS_KEY: ${{ secrets.TLS_KEY }}
|
TLS_KEY: ${{ secrets.TLS_KEY }}
|
||||||
|
|
||||||
|
- name: Chmod VPN files
|
||||||
|
run: |
|
||||||
|
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
|
||||||
|
|
||||||
- name: Install Open VPN
|
- name: Install Open VPN
|
||||||
run: |
|
run: |
|
||||||
sudo apt install apt-transport-https
|
sudo apt install apt-transport-https
|
||||||
@@ -67,6 +74,9 @@ jobs:
|
|||||||
- name: install pm2
|
- name: install pm2
|
||||||
run: npm i -g pm2
|
run: npm i -g pm2
|
||||||
|
|
||||||
|
- name: Fetch SASJS server
|
||||||
|
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
|
||||||
|
|
||||||
- name: Deploy sasjs-tests
|
- name: Deploy sasjs-tests
|
||||||
run: |
|
run: |
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
@@ -75,20 +85,23 @@ jobs:
|
|||||||
npm i
|
npm i
|
||||||
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
|
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
|
||||||
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
|
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
|
||||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
|
|
||||||
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
|
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
|
||||||
|
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
|
||||||
|
cat ./public/config.json
|
||||||
|
|
||||||
npm run update:adapter
|
npm run update:adapter
|
||||||
pm2 start --name sasjs-test npm -- start
|
pm2 start --name sasjs-test npm -- start
|
||||||
|
|
||||||
|
- name: Sleep for 10 seconds
|
||||||
|
run: sleep 10s
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Run cypress on sasjs
|
- name: Run cypress on sasjs
|
||||||
run: |
|
run: |
|
||||||
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
|
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
|
||||||
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
|
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
|
||||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
|
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
|
||||||
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
cat ./cypress.json
|
||||||
|
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
|
||||||
|
|
||||||
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
|
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||||
- name: Generate coverage report
|
|
||||||
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"cSpell.words": ["SASVIYA"]
|
||||||
|
}
|
||||||
@@ -151,7 +151,11 @@ The `request()` method also has optional parameters such as a config object and
|
|||||||
|
|
||||||
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
|
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
|
||||||
|
|
||||||
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
|
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
|
||||||
|
|
||||||
|
### Verbose Mode
|
||||||
|
|
||||||
|
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
|
||||||
|
|
||||||
### Session Manager
|
### Session Manager
|
||||||
|
|
||||||
@@ -273,6 +277,7 @@ Configuration on the client side involves passing an object on startup, which ca
|
|||||||
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
|
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
|
||||||
* `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.
|
* `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.
|
* `debug` - if `true` then SAS Logs and extra debug information is returned.
|
||||||
|
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
|
||||||
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
|
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
|
||||||
* `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`.
|
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
|
||||||
|
|||||||
@@ -4,93 +4,56 @@ const password = Cypress.env('password')
|
|||||||
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
|
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
|
||||||
|
|
||||||
context('sasjs-tests', function () {
|
context('sasjs-tests', function () {
|
||||||
this.beforeAll(() => {
|
before(() => {
|
||||||
cy.visit(sasjsTestsUrl)
|
cy.visit(sasjsTestsUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.reload()
|
cy.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successfull', (done) => {
|
function loginIfNeeded() {
|
||||||
cy.get('body').then(($body) => {
|
cy.get('body').then(($body) => {
|
||||||
cy.wait(1000).then(() => {
|
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||||
const startButton = $body.find(
|
cy.get('input[placeholder="User Name"]')
|
||||||
'.ui.massive.icon.primary.left.labeled.button'
|
.should('be.visible')
|
||||||
)[0]
|
.type(username)
|
||||||
|
cy.get('input[placeholder="Password"]')
|
||||||
if (
|
.should('be.visible')
|
||||||
!startButton ||
|
.type(password)
|
||||||
(startButton && !Cypress.dom.isVisible(startButton))
|
cy.get('.submit-button').should('be.visible').click()
|
||||||
) {
|
cy.get('input[placeholder="User Name"]').should('not.exist') // Wait for login to finish
|
||||||
cy.get('input[placeholder="User Name"]').type(username)
|
}
|
||||||
cy.get('input[placeholder="Password"]').type(password)
|
|
||||||
cy.get('.submit-button').click()
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
cy.get('.ui.massive.loading.primary.button', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('span.icon.failed')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Should have all tests successful', () => {
|
||||||
|
loginIfNeeded()
|
||||||
|
|
||||||
|
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||||
|
.should('be.visible')
|
||||||
|
.click()
|
||||||
|
|
||||||
|
cy.get('.ui.massive.loading.primary.button', {
|
||||||
|
timeout: testingFinishTimeout
|
||||||
|
}).should('not.exist')
|
||||||
|
|
||||||
|
cy.get('span.icon.failed').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successfull with debug on', (done) => {
|
it('Should have all tests successful with debug on', () => {
|
||||||
cy.get('body').then(($body) => {
|
loginIfNeeded()
|
||||||
cy.wait(1000).then(() => {
|
|
||||||
const startButton = $body.find(
|
|
||||||
'.ui.massive.icon.primary.left.labeled.button'
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
if (
|
cy.get('.ui.fitted.toggle.checkbox label').should('be.visible').click()
|
||||||
!startButton ||
|
|
||||||
(startButton && !Cypress.dom.isVisible(startButton))
|
|
||||||
) {
|
|
||||||
cy.get('input[placeholder="User Name"]').type(username)
|
|
||||||
cy.get('input[placeholder="Password"]').type(password)
|
|
||||||
cy.get('.submit-button').click()
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.get('.ui.fitted.toggle.checkbox label')
|
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||||
.click()
|
.should('be.visible')
|
||||||
.then(() => {
|
.click()
|
||||||
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
|
|
||||||
.should('not.exist')
|
cy.get('.ui.massive.loading.primary.button', {
|
||||||
.then(() => {
|
timeout: testingFinishTimeout
|
||||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
}).should('not.exist')
|
||||||
.click()
|
|
||||||
.then(() => {
|
cy.get('span.icon.failed').should('not.exist')
|
||||||
cy.get('.ui.massive.loading.primary.button', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
cy.get('span.icon.failed')
|
|
||||||
.should('not.exist')
|
|
||||||
.then(() => {
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
cypress/videos/sasjs.tests.ts.mp4
Normal file
BIN
cypress/videos/sasjs.tests.ts.mp4
Normal file
Binary file not shown.
@@ -41,7 +41,14 @@ module.exports = {
|
|||||||
// ],
|
// ],
|
||||||
|
|
||||||
// An object that configures minimum threshold enforcement for coverage results
|
// An object that configures minimum threshold enforcement for coverage results
|
||||||
// coverageThreshold: undefined,
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 64.03,
|
||||||
|
branches: 45.11,
|
||||||
|
functions: 54.18,
|
||||||
|
lines: 64.53
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// A path to a custom dependency extractor
|
// A path to a custom dependency extractor
|
||||||
// dependencyExtractor: undefined,
|
// dependencyExtractor: undefined,
|
||||||
@@ -135,6 +142,8 @@ module.exports = {
|
|||||||
// Options that will be passed to the testEnvironment
|
// Options that will be passed to the testEnvironment
|
||||||
// testEnvironmentOptions: {},
|
// testEnvironmentOptions: {},
|
||||||
|
|
||||||
|
testEnvironment: 'node',
|
||||||
|
|
||||||
// Adds a location field to test results
|
// Adds a location field to test results
|
||||||
// testLocationInResults: false,
|
// testLocationInResults: false,
|
||||||
|
|
||||||
|
|||||||
23310
package-lock.json
generated
23310
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -6,7 +6,7 @@
|
|||||||
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
|
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
|
||||||
"preinstall": "npm run nodeVersionMessage",
|
"preinstall": "npm run nodeVersionMessage",
|
||||||
"prebuild": "npm run nodeVersionMessage",
|
"prebuild": "npm run nodeVersionMessage",
|
||||||
"build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node",
|
"build": "npx rimraf build && npx rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && npx rimraf build/src && npx 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 build && cd build && npm version \"5.0.0\" && npm pack",
|
||||||
"publish:lib": "npm run build && cd build && npm publish",
|
"publish:lib": "npm run build && cd build && npm publish",
|
||||||
"lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
"lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
|
||||||
@@ -45,42 +45,44 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/webpack-preprocessor": "5.9.1",
|
"@cypress/webpack-preprocessor": "5.9.1",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.13",
|
||||||
"@types/jest": "27.4.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/mime": "2.0.3",
|
"@types/mime": "2.0.3",
|
||||||
"@types/pem": "1.9.6",
|
"@types/pem": "1.9.6",
|
||||||
"@types/tough-cookie": "4.0.2",
|
"@types/tough-cookie": "4.0.2",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"cp": "0.2.0",
|
"cp": "0.2.0",
|
||||||
"cypress": "7.7.0",
|
"cypress": "7.7.0",
|
||||||
"dotenv": "16.0.0",
|
"dotenv": "16.0.0",
|
||||||
"express": "4.17.3",
|
"express": "4.17.3",
|
||||||
"jest": "27.4.7",
|
"jest": "29.7.0",
|
||||||
"jest-extended": "2.0.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-extended": "4.0.2",
|
||||||
"node-polyfill-webpack-plugin": "1.1.4",
|
"node-polyfill-webpack-plugin": "1.1.4",
|
||||||
"path": "0.12.7",
|
"path": "0.12.7",
|
||||||
"pem": "1.14.5",
|
"pem": "1.14.5",
|
||||||
"prettier": "2.8.7",
|
"prettier": "2.8.7",
|
||||||
"process": "0.11.10",
|
"process": "0.11.10",
|
||||||
"rimraf": "3.0.2",
|
|
||||||
"semantic-release": "19.0.3",
|
"semantic-release": "19.0.3",
|
||||||
"terser-webpack-plugin": "5.3.6",
|
"terser-webpack-plugin": "5.3.6",
|
||||||
"ts-jest": "27.1.3",
|
"ts-jest": "29.2.6",
|
||||||
"ts-loader": "9.4.0",
|
"ts-loader": "9.4.0",
|
||||||
"tslint": "6.1.3",
|
"tslint": "6.1.3",
|
||||||
"tslint-config-prettier": "1.18.0",
|
"tslint-config-prettier": "1.18.0",
|
||||||
"typedoc": "0.23.24",
|
"typedoc": "0.23.24",
|
||||||
"typedoc-plugin-rename-defaults": "0.6.4",
|
"typedoc-plugin-rename-defaults": "0.6.4",
|
||||||
"typescript": "4.8.3",
|
"typescript": "4.9.5",
|
||||||
"webpack": "5.76.2",
|
"webpack": "5.76.2",
|
||||||
"webpack-cli": "4.9.2"
|
"webpack-cli": "4.9.2"
|
||||||
},
|
},
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sasjs/utils": "2.52.0",
|
"@sasjs/utils": "3.5.2",
|
||||||
"axios": "0.27.2",
|
"axios": "1.12.2",
|
||||||
"axios-cookiejar-support": "1.0.1",
|
"axios-cookiejar-support": "5.0.5",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.4",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
"tough-cookie": "4.1.3"
|
"tough-cookie": "4.1.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
SKIP_PREFLIGHT_CHECK=true
|
SKIP_PREFLIGHT_CHECK=true
|
||||||
|
# Removes index.html inline scripts
|
||||||
|
INLINE_RUNTIME_CHUNK=false
|
||||||
|
|||||||
15
sasjs-tests/craco.config.js
Normal file
15
sasjs-tests/craco.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// craco.config.js
|
||||||
|
// We use craco instead of react-scripts so we can override webpack config, to include source maps
|
||||||
|
// so we can debug @sasjs/adapter easier when tests fail
|
||||||
|
module.exports = {
|
||||||
|
webpack: {
|
||||||
|
configure: (webpackConfig, { env }) => {
|
||||||
|
// Disable optimizations in both development and production
|
||||||
|
webpackConfig.optimization.minimize = false;
|
||||||
|
webpackConfig.optimization.minimizer = [];
|
||||||
|
webpackConfig.optimization.concatenateModules = false;
|
||||||
|
webpackConfig.optimization.splitChunks = { cacheGroups: { default: false } };
|
||||||
|
return webpackConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
37878
sasjs-tests/package-lock.json
generated
37878
sasjs-tests/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
|
||||||
"@sasjs/test-framework": "1.5.7",
|
"@sasjs/test-framework": "1.5.7",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^26.0.20",
|
||||||
"@types/node": "^14.14.41",
|
"@types/node": "^14.14.41",
|
||||||
@@ -13,12 +14,12 @@
|
|||||||
"react": "^16.0.1",
|
"react": "^16.0.1",
|
||||||
"react-dom": "^16.0.1",
|
"react-dom": "^16.0.1",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "4.0.3",
|
||||||
"typescript": "^4.1.3"
|
"typescript": "^4.1.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "NODE_OPTIONS=--openssl-legacy-provider craco build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
|
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
|
||||||
@@ -42,6 +43,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"node-sass": "9.0.0"
|
"@craco/craco": "6.4.3",
|
||||||
|
"node-sass": "9.0.0",
|
||||||
|
"source-map-loader": "0.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
|
|||||||
echo "Cypress sasjs testing passed!"
|
echo "Cypress sasjs testing passed!"
|
||||||
else
|
else
|
||||||
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
|
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
|
||||||
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io:4gl.io/send/m.room.message?access_token=$1
|
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=$1
|
||||||
echo "Cypress sasjs testing failed!"
|
echo "Cypress sasjs testing failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -24,6 +24,26 @@
|
|||||||
"streamServiceName": "adapter-tests",
|
"streamServiceName": "adapter-tests",
|
||||||
"assetPaths": []
|
"assetPaths": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "viya",
|
||||||
|
"serverUrl": "",
|
||||||
|
"serverType": "SASVIYA",
|
||||||
|
"httpsAgentOptions": {
|
||||||
|
"allowInsecureRequests": false
|
||||||
|
},
|
||||||
|
"appLoc": "/Public/app/adapter-tests",
|
||||||
|
"deployConfig": {
|
||||||
|
"deployServicePack": true,
|
||||||
|
"deployScripts": []
|
||||||
|
},
|
||||||
|
"streamConfig": {
|
||||||
|
"streamWeb": true,
|
||||||
|
"streamWebFolder": "webv",
|
||||||
|
"webSourcePath": "build",
|
||||||
|
"streamServiceName": "adapter-tests",
|
||||||
|
"assetPaths": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const App = (): ReactElement<{}> => {
|
|||||||
basicTests(adapter, config.userName, config.password),
|
basicTests(adapter, config.userName, config.password),
|
||||||
sendArrTests(adapter, appLoc),
|
sendArrTests(adapter, appLoc),
|
||||||
sendObjTests(adapter),
|
sendObjTests(adapter),
|
||||||
specialCaseTests(adapter),
|
// specialCaseTests(adapter),
|
||||||
sasjsRequestTests(adapter),
|
sasjsRequestTests(adapter),
|
||||||
fileUploadTests(adapter)
|
fileUploadTests(adapter)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ export const basicTests = (
|
|||||||
return response.table1[0][0] === stringData.table1[0].col1
|
return response.table1[0][0] === stringData.table1[0].col1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Web request',
|
||||||
|
description: 'Should run the request with old web approach',
|
||||||
|
test: async () => {
|
||||||
|
const config: Partial<SASjsConfig> = {
|
||||||
|
useComputeApi: false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await adapter.request('common/sendArr', stringData, config)
|
||||||
|
},
|
||||||
|
assertion: (response: any) => {
|
||||||
|
return response.table1[0][0] === stringData.table1[0].col1
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Request with debug on',
|
title: 'Request with debug on',
|
||||||
description:
|
description:
|
||||||
@@ -159,20 +173,6 @@ export const basicTests = (
|
|||||||
sasjsConfig.debug === false
|
sasjsConfig.debug === false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Web request',
|
|
||||||
description: 'Should run the request with old web approach',
|
|
||||||
test: async () => {
|
|
||||||
const config: Partial<SASjsConfig> = {
|
|
||||||
useComputeApi: false
|
|
||||||
}
|
|
||||||
|
|
||||||
return await adapter.request('common/sendArr', stringData, config)
|
|
||||||
},
|
|
||||||
assertion: (response: any) => {
|
|
||||||
return response.table1[0][0] === stringData.table1[0].col1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
|
|||||||
return requests[0].SASWORK === null
|
return requests[0].SASWORK === null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Make error and capture log',
|
|
||||||
description:
|
|
||||||
'Should make an error and capture log, in the same time it is testing if debug override is working',
|
|
||||||
test: async () => {
|
|
||||||
return adapter
|
|
||||||
.request('common/makeErr', data, { debug: true })
|
|
||||||
.catch(() => {
|
|
||||||
const sasRequests = adapter.getSasRequests()
|
|
||||||
const makeErrRequest: any =
|
|
||||||
sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
|
|
||||||
null
|
|
||||||
|
|
||||||
if (!makeErrRequest) return false
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
makeErrRequest.logFile && makeErrRequest.logFile.length > 0
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
assertion: (response) => {
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// title: 'Make error and capture log',
|
||||||
|
// description:
|
||||||
|
// 'Should make an error and capture log, in the same time it is testing if debug override is working',
|
||||||
|
// test: async () => {
|
||||||
|
// return adapter
|
||||||
|
// .request('common/makeErr', data, { debug: true })
|
||||||
|
// .catch(() => {
|
||||||
|
// const sasRequests = adapter.getSasRequests()
|
||||||
|
// const makeErrRequest: any =
|
||||||
|
// sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
|
||||||
|
// null
|
||||||
|
|
||||||
|
// if (!makeErrRequest) return false
|
||||||
|
|
||||||
|
// return !!(
|
||||||
|
// makeErrRequest.logFile && makeErrRequest.logFile.length > 0
|
||||||
|
// )
|
||||||
|
// })
|
||||||
|
// },
|
||||||
|
// assertion: (response) => {
|
||||||
|
// return response
|
||||||
|
// }
|
||||||
|
// }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,6 +134,20 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
|
|||||||
return adapter.request('common/sendArr', moreSpecialCharData)
|
return adapter.request('common/sendArr', moreSpecialCharData)
|
||||||
},
|
},
|
||||||
assertion: (res: any) => {
|
assertion: (res: any) => {
|
||||||
|
// If sas session is `latin9` or `wlatin1` we can't process the special characters,
|
||||||
|
// But it can happen that response is broken JSON, so we first need to check if
|
||||||
|
// it's object and then check accordingly
|
||||||
|
|
||||||
|
if (typeof res === 'object') {
|
||||||
|
// Valid JSON response
|
||||||
|
if (res.SYSENCODING === 'latin9' || res.SYSENCODING === 'wlatin1')
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
// Since we got string response (broken JSON), we need to check with regex
|
||||||
|
const regex = /"SYSENCODING"\s*:\s*"(?:wlatin1|latin9)"/
|
||||||
|
if (regex.test(res)) return true
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
|
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
|
||||||
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
|
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
"esnext"
|
"esnext"
|
||||||
],
|
],
|
||||||
|
"sourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { generateTimestamp } from '@sasjs/utils/time'
|
import { generateTimestamp } from '@sasjs/utils/time'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { Sas9RequestClient } from './request/Sas9RequestClient'
|
import { Sas9RequestClient } from './request/Sas9RequestClient'
|
||||||
import { isUrl } from './utils'
|
import { isUrl } from './utils'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isRelativePath, isUri, isUrl } from './utils'
|
import { isRelativePath, isUri, isUrl } from './utils'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
Job,
|
Job,
|
||||||
Session,
|
Session,
|
||||||
@@ -25,9 +25,10 @@ import { prefixMessage } from '@sasjs/utils/error'
|
|||||||
import { pollJobState } from './api/viya/pollJobState'
|
import { pollJobState } from './api/viya/pollJobState'
|
||||||
import { getTokens } from './auth/getTokens'
|
import { getTokens } from './auth/getTokens'
|
||||||
import { uploadTables } from './api/viya/uploadTables'
|
import { uploadTables } from './api/viya/uploadTables'
|
||||||
import { executeScript } from './api/viya/executeScript'
|
import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
|
||||||
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
|
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
|
||||||
import { refreshTokensForViya } from './auth/refreshTokensForViya'
|
import { refreshTokensForViya } from './auth/refreshTokensForViya'
|
||||||
|
import { FileResource } from './types/FileResource'
|
||||||
|
|
||||||
interface JobExecutionResult {
|
interface JobExecutionResult {
|
||||||
result?: { result: object }
|
result?: { result: object }
|
||||||
@@ -293,7 +294,7 @@ export class SASViyaApiClient {
|
|||||||
printPid = false,
|
printPid = false,
|
||||||
variables?: MacroVar
|
variables?: MacroVar
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return executeScript(
|
return executeOnComputeApi(
|
||||||
this.requestClient,
|
this.requestClient,
|
||||||
this.sessionManager,
|
this.sessionManager,
|
||||||
this.rootFolderName,
|
this.rootFolderName,
|
||||||
@@ -311,6 +312,84 @@ export class SASViyaApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async getFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
const fileUri = await this.getFileUri(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
).catch((err) => {
|
||||||
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return await this.requestClient
|
||||||
|
.get<string>(`${this.serverUrl}${fileUri}/content`, accessToken)
|
||||||
|
.then((res) => res.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param content - the new content to be written to the file
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async updateFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
content: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
const fileUri = await this.getFileUri(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
).catch((err) => {
|
||||||
|
throw prefixMessage(
|
||||||
|
err,
|
||||||
|
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch the file resource details to get the Etag and content type
|
||||||
|
const { result: originalFileResource, etag } =
|
||||||
|
await this.requestClient.get<FileResource>(
|
||||||
|
`${this.serverUrl}${fileUri}`,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!originalFileResource || !etag)
|
||||||
|
throw new Error(
|
||||||
|
`File ${fileName} does not have an ETag, or request failed.`
|
||||||
|
)
|
||||||
|
|
||||||
|
return await this.requestClient
|
||||||
|
.put<FileResource>(
|
||||||
|
`${this.serverUrl}${fileUri}/content`,
|
||||||
|
content,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
'If-Match': etag,
|
||||||
|
'Content-Type': originalFileResource.contentType
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => res.result)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a folder. Path to the folder is required.
|
* Fetches a folder. Path to the folder is required.
|
||||||
* @param folderPath - the absolute path to the folder.
|
* @param folderPath - the absolute path to the folder.
|
||||||
@@ -791,14 +870,14 @@ export class SASViyaApiClient {
|
|||||||
_webin_file_count: files.length,
|
_webin_file_count: files.length,
|
||||||
_OMITJSONLISTING: true,
|
_OMITJSONLISTING: true,
|
||||||
_OMITJSONLOG: true,
|
_OMITJSONLOG: true,
|
||||||
_OMITSESSIONRESULTS: true,
|
_omitSessionResults: false,
|
||||||
_OMITTEXTLISTING: true,
|
_OMITTEXTLISTING: true,
|
||||||
_OMITTEXTLOG: true
|
_OMITTEXTLOG: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
jobArguments['_OMITTEXTLOG'] = 'false'
|
jobArguments['_OMITTEXTLOG'] = 'false'
|
||||||
jobArguments['_OMITSESSIONRESULTS'] = 'false'
|
jobArguments['_omitSessionResults'] = 'false'
|
||||||
jobArguments['_DEBUG'] = 131
|
jobArguments['_DEBUG'] = 131
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,6 +1020,7 @@ export class SASViyaApiClient {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!folder) return undefined
|
if (!folder) return undefined
|
||||||
|
|
||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,6 +1032,30 @@ export class SASViyaApiClient {
|
|||||||
return `/folders/folders/${folderDetails.id}`
|
return `/folders/folders/${folderDetails.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFileUri(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const folderMembers = await this.listFolder(folderPath, accessToken, 1000, {
|
||||||
|
returnDetails: true
|
||||||
|
}).catch((err) => {
|
||||||
|
throw prefixMessage(err, `Error while listing folder: ${folderPath}. `)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!folderMembers || !folderMembers.length)
|
||||||
|
throw new Error(`No members found in folder: ${folderPath}`)
|
||||||
|
|
||||||
|
const fileUri = folderMembers.find(
|
||||||
|
(member) => member.name === fileName
|
||||||
|
)?.uri
|
||||||
|
|
||||||
|
if (!fileUri)
|
||||||
|
throw new Error(`File ${fileName} not found in folder: ${folderPath}`)
|
||||||
|
|
||||||
|
return fileUri
|
||||||
|
}
|
||||||
|
|
||||||
private async getRecycleBinUri(accessToken?: string) {
|
private async getRecycleBinUri(accessToken?: string) {
|
||||||
const url = '/folders/folders/@myRecycleBin'
|
const url = '/folders/folders/@myRecycleBin'
|
||||||
|
|
||||||
@@ -999,14 +1103,19 @@ export class SASViyaApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists children folders for given Viya folder.
|
* Lists children folders/files for given Viya folder.
|
||||||
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
||||||
* @param accessToken - an access token for authorizing the request.
|
* @param accessToken - an access token for authorizing the request.
|
||||||
|
* @param {Object} [options] - Additional options.
|
||||||
|
* @param {boolean} [options.returnDetails=false] - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
|
||||||
*/
|
*/
|
||||||
public async listFolder(
|
public async listFolder(
|
||||||
sourceFolder: string,
|
sourceFolder: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
limit: number = 20
|
limit: number = 20,
|
||||||
|
options?: {
|
||||||
|
returnDetails?: boolean
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// checks if 'sourceFolder' is already a URI
|
// checks if 'sourceFolder' is already a URI
|
||||||
const sourceFolderUri = isUri(sourceFolder)
|
const sourceFolderUri = isUri(sourceFolder)
|
||||||
@@ -1018,11 +1127,20 @@ export class SASViyaApiClient {
|
|||||||
accessToken
|
accessToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let membersToReturn = []
|
||||||
|
|
||||||
if (members && members.items) {
|
if (members && members.items) {
|
||||||
return members.items.map((item: any) => item.name)
|
// If returnDetails is true, return full member details
|
||||||
} else {
|
if (options?.returnDetails) {
|
||||||
return []
|
membersToReturn = members.items
|
||||||
|
} else {
|
||||||
|
// If returnDetails is false, return only member names
|
||||||
|
membersToReturn = members.items.map((item: any) => item.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return members without Etag
|
||||||
|
return membersToReturn
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
132
src/SASjs.ts
132
src/SASjs.ts
@@ -4,7 +4,13 @@ import {
|
|||||||
UploadFile,
|
UploadFile,
|
||||||
EditContextInput,
|
EditContextInput,
|
||||||
PollOptions,
|
PollOptions,
|
||||||
LoginMechanism
|
LoginMechanism,
|
||||||
|
VerboseMode,
|
||||||
|
ErrorResponse,
|
||||||
|
LoginOptions,
|
||||||
|
LoginResult,
|
||||||
|
ExecutionQuery,
|
||||||
|
Tables
|
||||||
} from './types'
|
} from './types'
|
||||||
import { SASViyaApiClient } from './SASViyaApiClient'
|
import { SASViyaApiClient } from './SASViyaApiClient'
|
||||||
import { SAS9ApiClient } from './SAS9ApiClient'
|
import { SAS9ApiClient } from './SAS9ApiClient'
|
||||||
@@ -29,8 +35,7 @@ import {
|
|||||||
Sas9JobExecutor,
|
Sas9JobExecutor,
|
||||||
FileUploader
|
FileUploader
|
||||||
} from './job-execution'
|
} from './job-execution'
|
||||||
import { ErrorResponse } from './types/errors'
|
import { AxiosResponse, AxiosError } from 'axios'
|
||||||
import { LoginOptions, LoginResult } from './types/Login'
|
|
||||||
|
|
||||||
interface ExecuteScriptParams {
|
interface ExecuteScriptParams {
|
||||||
linesOfCode: string[]
|
linesOfCode: string[]
|
||||||
@@ -157,6 +162,23 @@ export default class SASjs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes job on SASJS server.
|
||||||
|
* @param query - an object containing job path and debug level.
|
||||||
|
* @param appLoc - an application path.
|
||||||
|
* @param authConfig - an object for authentication.
|
||||||
|
* @returns a promise that resolves into job execution result and log.
|
||||||
|
*/
|
||||||
|
public async executeJob(
|
||||||
|
query: ExecutionQuery,
|
||||||
|
appLoc: string,
|
||||||
|
authConfig?: AuthConfig
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('executeScript', [ServerType.Sasjs])
|
||||||
|
|
||||||
|
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets compute contexts.
|
* Gets compute contexts.
|
||||||
* @param accessToken - an access token for an authorised user.
|
* @param accessToken - an access token for an authorised user.
|
||||||
@@ -390,6 +412,51 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async getFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('getFileContent', [ServerType.SasViya])
|
||||||
|
|
||||||
|
return await this.sasViyaApiClient!.getFileContent(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the file content for a file in the specified folder.
|
||||||
|
*
|
||||||
|
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
|
||||||
|
* @param fileName - the name of the file in the `folderPath`
|
||||||
|
* @param content - the new content to be written to the file
|
||||||
|
* @param accessToken - an access token for authorizing the request
|
||||||
|
*/
|
||||||
|
public async updateFileContent(
|
||||||
|
folderPath: string,
|
||||||
|
fileName: string,
|
||||||
|
content: string,
|
||||||
|
accessToken?: string
|
||||||
|
) {
|
||||||
|
this.isMethodSupported('updateFileContent', [ServerType.SasViya])
|
||||||
|
|
||||||
|
return await this.sasViyaApiClient!.updateFileContent(
|
||||||
|
folderPath,
|
||||||
|
fileName,
|
||||||
|
content,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches a folder from the SAS file system.
|
* Fetches a folder from the SAS file system.
|
||||||
* @param folderPath - path of the folder to be fetched.
|
* @param folderPath - path of the folder to be fetched.
|
||||||
@@ -415,18 +482,23 @@ export default class SASjs {
|
|||||||
* Lists children folders for given Viya folder.
|
* Lists children folders for given Viya folder.
|
||||||
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
|
||||||
* @param accessToken - an access token for authorizing the request.
|
* @param accessToken - an access token for authorizing the request.
|
||||||
|
* @param returnDetails - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
|
||||||
*/
|
*/
|
||||||
public async listFolder(
|
public async listFolder(
|
||||||
sourceFolder: string,
|
sourceFolder: string,
|
||||||
accessToken?: string,
|
accessToken?: string,
|
||||||
limit?: number
|
limit?: number,
|
||||||
|
returnDetails = false
|
||||||
) {
|
) {
|
||||||
this.isMethodSupported('listFolder', [ServerType.SasViya])
|
this.isMethodSupported('listFolder', [ServerType.SasViya])
|
||||||
|
|
||||||
return await this.sasViyaApiClient?.listFolder(
|
return await this.sasViyaApiClient?.listFolder(
|
||||||
sourceFolder,
|
sourceFolder,
|
||||||
accessToken,
|
accessToken,
|
||||||
limit
|
limit,
|
||||||
|
{
|
||||||
|
returnDetails
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,6 +926,7 @@ export default class SASjs {
|
|||||||
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
|
||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
|
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
|
||||||
*/
|
*/
|
||||||
public async startComputeJob(
|
public async startComputeJob(
|
||||||
sasJob: string,
|
sasJob: string,
|
||||||
@@ -863,7 +936,8 @@ export default class SASjs {
|
|||||||
waitForResult?: boolean,
|
waitForResult?: boolean,
|
||||||
pollOptions?: PollOptions,
|
pollOptions?: PollOptions,
|
||||||
printPid = false,
|
printPid = false,
|
||||||
variables?: MacroVar
|
variables?: MacroVar,
|
||||||
|
verboseMode?: VerboseMode
|
||||||
) {
|
) {
|
||||||
config = {
|
config = {
|
||||||
...this.sasjsConfig,
|
...this.sasjsConfig,
|
||||||
@@ -877,6 +951,11 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verboseMode) {
|
||||||
|
this.requestClient?.setVerboseMode(verboseMode)
|
||||||
|
this.requestClient?.enableVerboseMode()
|
||||||
|
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
|
||||||
|
|
||||||
return this.sasViyaApiClient?.executeComputeJob(
|
return this.sasViyaApiClient?.executeComputeJob(
|
||||||
sasJob,
|
sasJob,
|
||||||
config.contextName,
|
config.contextName,
|
||||||
@@ -970,7 +1049,8 @@ export default class SASjs {
|
|||||||
this.requestClient = new RequestClientClass(
|
this.requestClient = new RequestClientClass(
|
||||||
this.sasjsConfig.serverUrl,
|
this.sasjsConfig.serverUrl,
|
||||||
this.sasjsConfig.httpsAgentOptions,
|
this.sasjsConfig.httpsAgentOptions,
|
||||||
this.sasjsConfig.requestHistoryLimit
|
this.sasjsConfig.requestHistoryLimit,
|
||||||
|
this.sasjsConfig.verbose
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.requestClient.setConfig(
|
this.requestClient.setConfig(
|
||||||
@@ -1134,4 +1214,42 @@ export default class SASjs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables verbose mode that will log a summary of every HTTP response.
|
||||||
|
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
|
||||||
|
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||||
|
*/
|
||||||
|
public enableVerboseMode(
|
||||||
|
successCallBack?: (response: AxiosResponse) => AxiosResponse,
|
||||||
|
errorCallBack?: (response: AxiosError) => AxiosError
|
||||||
|
) {
|
||||||
|
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off verbose mode to log every HTTP response.
|
||||||
|
*/
|
||||||
|
public disableVerboseMode() {
|
||||||
|
this.requestClient?.disableVerboseMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets verbose mode.
|
||||||
|
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
|
||||||
|
*/
|
||||||
|
public setVerboseMode = (verboseMode: VerboseMode) => {
|
||||||
|
this.requestClient?.setVerboseMode(verboseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tables class containing one or more tables to be sent to
|
||||||
|
* SAS.
|
||||||
|
* @param table - initial table data
|
||||||
|
* @param macroName - macro name
|
||||||
|
* @returns Tables class
|
||||||
|
*/
|
||||||
|
Tables(table: Record<string, any>, macroName: string) {
|
||||||
|
return new Tables(table, macroName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { ExecutionQuery } from './types'
|
import { ExecutionQuery } from './types'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Session, Context, SessionVariable } from './types'
|
import { Session, Context, SessionVariable, SessionState } from './types'
|
||||||
import { NoSessionStateError } from './types/errors'
|
import { NoSessionStateError } from './types/errors'
|
||||||
import { asyncForEach, isUrl } from './utils'
|
import { asyncForEach, isUrl } from './utils'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
@@ -12,6 +12,7 @@ interface ApiErrorResponse {
|
|||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private loggedErrors: NoSessionStateError[] = []
|
private loggedErrors: NoSessionStateError[] = []
|
||||||
|
private sessionStateLinkError = 'Error while getting session state link. '
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private serverUrl: string,
|
private serverUrl: string,
|
||||||
@@ -28,7 +29,7 @@ export class SessionManager {
|
|||||||
private _debug: boolean = false
|
private _debug: boolean = false
|
||||||
private printedSessionState = {
|
private printedSessionState = {
|
||||||
printed: false,
|
printed: false,
|
||||||
state: ''
|
state: SessionState.NoState
|
||||||
}
|
}
|
||||||
|
|
||||||
public get debug() {
|
public get debug() {
|
||||||
@@ -265,6 +266,18 @@ export class SessionManager {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add response etag to Session object.
|
||||||
|
createdSession.etag = etag
|
||||||
|
|
||||||
|
// Get session state link.
|
||||||
|
const stateLink = createdSession.links.find((link) => link.rel === 'state')
|
||||||
|
|
||||||
|
// Throw error if session state link is not present.
|
||||||
|
if (!stateLink) throw this.sessionStateLinkError
|
||||||
|
|
||||||
|
// Add session state link to Session object.
|
||||||
|
createdSession.stateUrl = stateLink.href
|
||||||
|
|
||||||
await this.waitForSession(createdSession, etag, accessToken)
|
await this.waitForSession(createdSession, etag, accessToken)
|
||||||
|
|
||||||
this.sessions.push(createdSession)
|
this.sessions.push(createdSession)
|
||||||
@@ -327,32 +340,30 @@ export class SessionManager {
|
|||||||
etag: string | null,
|
etag: string | null,
|
||||||
accessToken?: string
|
accessToken?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
let { state: sessionState } = session
|
||||||
|
const { stateUrl } = session
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
let sessionState = session.state
|
|
||||||
|
|
||||||
const stateLink = session.links.find((l: any) => l.rel === 'state')
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
sessionState === 'pending' ||
|
sessionState === SessionState.Pending ||
|
||||||
sessionState === 'running' ||
|
sessionState === SessionState.Running ||
|
||||||
sessionState === ''
|
sessionState === SessionState.NoState
|
||||||
) {
|
) {
|
||||||
if (stateLink) {
|
if (stateUrl) {
|
||||||
if (this.debug && !this.printedSessionState.printed) {
|
if (this.debug && !this.printedSessionState.printed) {
|
||||||
logger.info(`Polling: ${this.serverUrl + stateLink.href}`)
|
logger.info(`Polling: ${this.serverUrl + stateUrl}`)
|
||||||
|
|
||||||
this.printedSessionState.printed = true
|
this.printedSessionState.printed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${this.serverUrl}${stateLink.href}?wait=30`
|
const url = `${this.serverUrl}${stateUrl}?wait=30`
|
||||||
|
|
||||||
const { result: state, responseStatus: responseStatus } =
|
const { result: state, responseStatus: responseStatus } =
|
||||||
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
||||||
throw prefixMessage(err, 'Error while waiting for session. ')
|
throw prefixMessage(err, 'Error while waiting for session. ')
|
||||||
})
|
})
|
||||||
|
|
||||||
sessionState = state.trim()
|
sessionState = state.trim() as SessionState
|
||||||
|
|
||||||
if (this.debug && this.printedSessionState.state !== sessionState) {
|
if (this.debug && this.printedSessionState.state !== sessionState) {
|
||||||
logger.info(`Current session state is '${sessionState}'`)
|
logger.info(`Current session state is '${sessionState}'`)
|
||||||
@@ -364,7 +375,7 @@ export class SessionManager {
|
|||||||
if (!sessionState) {
|
if (!sessionState) {
|
||||||
const stateError = new NoSessionStateError(
|
const stateError = new NoSessionStateError(
|
||||||
responseStatus,
|
responseStatus,
|
||||||
this.serverUrl + stateLink.href,
|
this.serverUrl + stateUrl,
|
||||||
session.links.find((l: any) => l.rel === 'log')?.href as string
|
session.links.find((l: any) => l.rel === 'log')?.href as string
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -386,7 +397,7 @@ export class SessionManager {
|
|||||||
|
|
||||||
return sessionState
|
return sessionState
|
||||||
} else {
|
} else {
|
||||||
throw 'Error while getting session state link. '
|
throw this.sessionStateLinkError
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.loggedErrors = []
|
this.loggedErrors = []
|
||||||
@@ -413,7 +424,7 @@ export class SessionManager {
|
|||||||
return await this.requestClient
|
return await this.requestClient
|
||||||
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
||||||
.then((res) => ({
|
.then((res) => ({
|
||||||
result: res.result as string,
|
result: res.result as SessionState,
|
||||||
responseStatus: res.status
|
responseStatus: res.status
|
||||||
}))
|
}))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ import { formatDataForRequest } from '../../utils/formatDataForRequest'
|
|||||||
import { pollJobState, JobState } from './pollJobState'
|
import { pollJobState, JobState } from './pollJobState'
|
||||||
import { uploadTables } from './uploadTables'
|
import { uploadTables } from './uploadTables'
|
||||||
|
|
||||||
|
interface JobRequestBody {
|
||||||
|
[key: string]: number | string | string[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes code on the current SAS Viya server.
|
* Executes SAS program on the current SAS Viya server using Compute API.
|
||||||
* @param jobPath - the path to the file being submitted for execution.
|
* @param jobPath - the path to the file being submitted for execution.
|
||||||
* @param linesOfCode - an array of code lines to execute.
|
* @param linesOfCode - an array of code lines to execute.
|
||||||
* @param contextName - the context to execute the code in.
|
* @param contextName - the context to execute the code in.
|
||||||
@@ -29,7 +33,7 @@ import { uploadTables } from './uploadTables'
|
|||||||
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
|
||||||
* @param variables - an object that represents macro variables.
|
* @param variables - an object that represents macro variables.
|
||||||
*/
|
*/
|
||||||
export async function executeScript(
|
export async function executeOnComputeApi(
|
||||||
requestClient: RequestClient,
|
requestClient: RequestClient,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
rootFolderName: string,
|
rootFolderName: string,
|
||||||
@@ -46,6 +50,7 @@ export async function executeScript(
|
|||||||
variables?: MacroVar
|
variables?: MacroVar
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
let access_token = (authConfig || {}).access_token
|
let access_token = (authConfig || {}).access_token
|
||||||
|
|
||||||
if (authConfig) {
|
if (authConfig) {
|
||||||
;({ access_token } = await getTokens(requestClient, authConfig))
|
;({ access_token } = await getTokens(requestClient, authConfig))
|
||||||
}
|
}
|
||||||
@@ -78,27 +83,13 @@ export async function executeScript(
|
|||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Triggered '${relativeJobPath}' with PID ${
|
`Triggering '${relativeJobPath}' with PID ${
|
||||||
jobIdVariable.value
|
jobIdVariable.value
|
||||||
} at ${timestampToYYYYMMDDHHMMSS()}`
|
} 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
|
let fileName
|
||||||
|
|
||||||
if (isRelativePath(jobPath)) {
|
if (isRelativePath(jobPath)) {
|
||||||
@@ -107,6 +98,7 @@ export async function executeScript(
|
|||||||
}`
|
}`
|
||||||
} else {
|
} else {
|
||||||
const jobPathParts = jobPath.split('/')
|
const jobPathParts = jobPath.split('/')
|
||||||
|
|
||||||
fileName = jobPathParts.pop()
|
fileName = jobPathParts.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +110,6 @@ export async function executeScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (variables) jobVariables = { ...jobVariables, ...variables }
|
if (variables) jobVariables = { ...jobVariables, ...variables }
|
||||||
|
|
||||||
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
|
||||||
|
|
||||||
let files: any[] = []
|
let files: any[] = []
|
||||||
@@ -145,12 +136,12 @@ export async function executeScript(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute job in session
|
// Execute job in session
|
||||||
const jobRequestBody = {
|
const jobRequestBody: JobRequestBody = {
|
||||||
name: fileName,
|
name: fileName || 'Default Job Name',
|
||||||
description: 'Powered by SASjs',
|
description: 'Powered by SASjs',
|
||||||
code: linesOfCode,
|
code: linesOfCode,
|
||||||
variables: jobVariables,
|
variables: jobVariables,
|
||||||
arguments: jobArguments
|
version: 2
|
||||||
}
|
}
|
||||||
|
|
||||||
const { result: postedJob, etag } = await requestClient
|
const { result: postedJob, etag } = await requestClient
|
||||||
@@ -179,16 +170,21 @@ export async function executeScript(
|
|||||||
postedJob,
|
postedJob,
|
||||||
debug,
|
debug,
|
||||||
authConfig,
|
authConfig,
|
||||||
pollOptions
|
pollOptions,
|
||||||
|
{
|
||||||
|
session,
|
||||||
|
sessionManager
|
||||||
|
}
|
||||||
).catch(async (err) => {
|
).catch(async (err) => {
|
||||||
const error = err?.response?.data
|
const error = err?.response?.data
|
||||||
const result = /err=[0-9]*,/.exec(error)
|
const result = /err=[0-9]*,/.exec(error)
|
||||||
|
|
||||||
const errorCode = '5113'
|
const errorCode = '5113'
|
||||||
|
|
||||||
if (result?.[0]?.slice(4, -1) === errorCode) {
|
if (result?.[0]?.slice(4, -1) === errorCode) {
|
||||||
|
const logCount = 1000000
|
||||||
const sessionLogUrl =
|
const sessionLogUrl =
|
||||||
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
||||||
const logCount = 1000000
|
|
||||||
err.log = await fetchLogByChunks(
|
err.log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -196,6 +192,7 @@ export async function executeScript(
|
|||||||
logCount
|
logCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw prefixMessage(err, 'Error while polling job status. ')
|
throw prefixMessage(err, 'Error while polling job status. ')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,12 +211,12 @@ export async function executeScript(
|
|||||||
|
|
||||||
let jobResult
|
let jobResult
|
||||||
let log = ''
|
let log = ''
|
||||||
|
|
||||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||||
|
|
||||||
if (debug && logLink) {
|
if (debug && logLink) {
|
||||||
const logUrl = `${logLink.href}/content`
|
const logUrl = `${logLink.href}/content`
|
||||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||||
|
|
||||||
log = await fetchLogByChunks(
|
log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -232,9 +229,7 @@ export async function executeScript(
|
|||||||
throw new ComputeJobExecutionError(currentJob, log)
|
throw new ComputeJobExecutionError(currentJob, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!expectWebout) {
|
if (!expectWebout) return { job: currentJob, log }
|
||||||
return { job: currentJob, log }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
||||||
|
|
||||||
@@ -245,6 +240,7 @@ export async function executeScript(
|
|||||||
if (logLink) {
|
if (logLink) {
|
||||||
const logUrl = `${logLink.href}/content`
|
const logUrl = `${logLink.href}/content`
|
||||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||||
|
|
||||||
log = await fetchLogByChunks(
|
log = await fetchLogByChunks(
|
||||||
requestClient,
|
requestClient,
|
||||||
access_token!,
|
access_token!,
|
||||||
@@ -279,7 +275,7 @@ export async function executeScript(
|
|||||||
const error = e as HttpError
|
const error = e as HttpError
|
||||||
|
|
||||||
if (error.status === 404) {
|
if (error.status === 404) {
|
||||||
return executeScript(
|
return executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
rootFolderName,
|
rootFolderName,
|
||||||
@@ -3,7 +3,7 @@ import { Job, PollOptions, PollStrategy } from '../..'
|
|||||||
import { getTokens } from '../../auth/getTokens'
|
import { getTokens } from '../../auth/getTokens'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { JobStatePollError } from '../../types/errors'
|
import { JobStatePollError } from '../../types/errors'
|
||||||
import { Link, WriteStream } from '../../types'
|
import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
|
||||||
import { delay, isNode } from '../../utils'
|
import { delay, isNode } from '../../utils'
|
||||||
|
|
||||||
export enum JobState {
|
export enum JobState {
|
||||||
@@ -37,6 +37,7 @@ export enum JobState {
|
|||||||
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
|
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
|
||||||
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
|
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
|
||||||
* ]
|
* ]
|
||||||
|
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
|
||||||
* @returns - a promise which resolves with a job state
|
* @returns - a promise which resolves with a job state
|
||||||
*/
|
*/
|
||||||
export async function pollJobState(
|
export async function pollJobState(
|
||||||
@@ -44,7 +45,8 @@ export async function pollJobState(
|
|||||||
postedJob: Job,
|
postedJob: Job,
|
||||||
debug: boolean,
|
debug: boolean,
|
||||||
authConfig?: AuthConfig,
|
authConfig?: AuthConfig,
|
||||||
pollOptions?: PollOptions
|
pollOptions?: PollOptions,
|
||||||
|
jobSessionManager?: JobSessionManager
|
||||||
): Promise<JobState> {
|
): Promise<JobState> {
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
|
|
||||||
@@ -127,7 +129,8 @@ export async function pollJobState(
|
|||||||
pollOptions,
|
pollOptions,
|
||||||
authConfig,
|
authConfig,
|
||||||
streamLog,
|
streamLog,
|
||||||
logFileStream
|
logFileStream,
|
||||||
|
jobSessionManager
|
||||||
)
|
)
|
||||||
|
|
||||||
currentState = result.state
|
currentState = result.state
|
||||||
@@ -158,7 +161,8 @@ export async function pollJobState(
|
|||||||
defaultPollOptions,
|
defaultPollOptions,
|
||||||
authConfig,
|
authConfig,
|
||||||
streamLog,
|
streamLog,
|
||||||
logFileStream
|
logFileStream,
|
||||||
|
jobSessionManager
|
||||||
)
|
)
|
||||||
|
|
||||||
currentState = result.state
|
currentState = result.state
|
||||||
@@ -208,7 +212,21 @@ const needsRetry = (state: string) =>
|
|||||||
state === JobState.Pending ||
|
state === JobState.Pending ||
|
||||||
state === JobState.Unavailable
|
state === JobState.Unavailable
|
||||||
|
|
||||||
const doPoll = async (
|
/**
|
||||||
|
* Polls job state.
|
||||||
|
* @param requestClient - the pre-configured HTTP request client.
|
||||||
|
* @param postedJob - the relative or absolute path to the job.
|
||||||
|
* @param currentState - current job state.
|
||||||
|
* @param debug - sets the _debug flag in the job arguments.
|
||||||
|
* @param pollCount - current poll count.
|
||||||
|
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath.
|
||||||
|
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
|
||||||
|
* @param streamLog - indicates if job log should be streamed.
|
||||||
|
* @param logStream - job log stream.
|
||||||
|
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
|
||||||
|
* @returns - a promise which resolves with a job state
|
||||||
|
*/
|
||||||
|
export const doPoll = async (
|
||||||
requestClient: RequestClient,
|
requestClient: RequestClient,
|
||||||
postedJob: Job,
|
postedJob: Job,
|
||||||
currentState: JobState,
|
currentState: JobState,
|
||||||
@@ -217,7 +235,8 @@ const doPoll = async (
|
|||||||
pollOptions: PollOptions,
|
pollOptions: PollOptions,
|
||||||
authConfig?: AuthConfig,
|
authConfig?: AuthConfig,
|
||||||
streamLog?: boolean,
|
streamLog?: boolean,
|
||||||
logStream?: WriteStream
|
logStream?: WriteStream,
|
||||||
|
jobSessionManager?: JobSessionManager
|
||||||
): Promise<{ state: JobState; pollCount: number }> => {
|
): Promise<{ state: JobState; pollCount: number }> => {
|
||||||
const { maxPollCount, pollInterval } = pollOptions
|
const { maxPollCount, pollInterval } = pollOptions
|
||||||
const logger = process.logger || console
|
const logger = process.logger || console
|
||||||
@@ -229,6 +248,40 @@ const doPoll = async (
|
|||||||
let startLogLine = 0
|
let startLogLine = 0
|
||||||
|
|
||||||
while (needsRetry(state) && pollCount <= maxPollCount) {
|
while (needsRetry(state) && pollCount <= maxPollCount) {
|
||||||
|
// Check parent session state on every 10th job state poll.
|
||||||
|
if (jobSessionManager && pollCount && pollCount % 10 === 0 && authConfig) {
|
||||||
|
const { session, sessionManager } = jobSessionManager
|
||||||
|
const { stateUrl, etag, id: sessionId } = session
|
||||||
|
const { access_token } = authConfig
|
||||||
|
const { id: jobId } = postedJob
|
||||||
|
|
||||||
|
// Get session state.
|
||||||
|
const { result: sessionState, responseStatus } = await sessionManager[
|
||||||
|
'getSessionState'
|
||||||
|
](stateUrl, etag, access_token).catch((err) => {
|
||||||
|
// Handle error while getting session state.
|
||||||
|
throw new JobStatePollError(jobId, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Checks if session state is equal to 'running' or 'idle'.
|
||||||
|
const isSessionStatesHealthy = (state: string) =>
|
||||||
|
[SessionState.Running, SessionState.Idle].includes(
|
||||||
|
state as SessionState
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear parent session and throw an error if session state is not
|
||||||
|
// 'running', 'idle' or response status is not 200.
|
||||||
|
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
|
||||||
|
sessionManager.clearSession(sessionId, access_token)
|
||||||
|
|
||||||
|
const sessionError = isSessionStatesHealthy(sessionState)
|
||||||
|
? `Session response status is not 200. Session response status is ${responseStatus}.`
|
||||||
|
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
|
||||||
|
|
||||||
|
throw new JobStatePollError(jobId, new Error(sessionError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = await getJobState(
|
state = await getJobState(
|
||||||
requestClient,
|
requestClient,
|
||||||
postedJob,
|
postedJob,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { RequestClient } from '../../../request/RequestClient'
|
import { RequestClient } from '../../../request/RequestClient'
|
||||||
import { SessionManager } from '../../../SessionManager'
|
import { SessionManager } from '../../../SessionManager'
|
||||||
import { executeScript } from '../executeScript'
|
import { executeOnComputeApi } from '../executeOnComputeApi'
|
||||||
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
|
import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
|
||||||
import * as pollJobStateModule from '../pollJobState'
|
import * as pollJobStateModule from '../pollJobState'
|
||||||
import * as uploadTablesModule from '../uploadTables'
|
import * as uploadTablesModule from '../uploadTables'
|
||||||
import * as getTokensModule from '../../../auth/getTokens'
|
import * as getTokensModule from '../../../auth/getTokens'
|
||||||
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
||||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||||
import { PollOptions } from '../../../types'
|
import { PollOptions, JobSessionManager } from '../../../types'
|
||||||
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
||||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
|
it('should not try to get fresh tokens if an authConfig is not provided', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -38,7 +38,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should try to get fresh tokens if an authConfig is provided', async () => {
|
it('should try to get fresh tokens if an authConfig is provided', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -55,7 +55,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should get a session from the session manager before executing', async () => {
|
it('should get a session from the session manager before executing', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -72,7 +72,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'getSession')
|
.spyOn(sessionManager, 'getSession')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -85,7 +85,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the PID when printPid is true', async () => {
|
it('should fetch the PID when printPid is true', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -113,7 +113,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'getVariable')
|
.spyOn(sessionManager, 'getVariable')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -139,7 +139,7 @@ describe('executeScript', () => {
|
|||||||
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
|
Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
|
||||||
)
|
)
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -163,7 +163,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should format data as CSV when it does not contain semicolons', async () => {
|
it('should format data as CSV when it does not contain semicolons', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -189,7 +189,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -217,14 +217,7 @@ describe('executeScript', () => {
|
|||||||
sasjs_tables: 'foo',
|
sasjs_tables: 'foo',
|
||||||
sasjs0data: 'bar'
|
sasjs0data: 'bar'
|
||||||
},
|
},
|
||||||
arguments: {
|
version: 2
|
||||||
_contextName: 'test context',
|
|
||||||
_OMITJSONLISTING: true,
|
|
||||||
_OMITJSONLOG: true,
|
|
||||||
_OMITSESSIONRESULTS: true,
|
|
||||||
_OMITTEXTLISTING: true,
|
|
||||||
_OMITTEXTLOG: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mockAuthConfig.access_token
|
mockAuthConfig.access_token
|
||||||
)
|
)
|
||||||
@@ -235,7 +228,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(formatDataModule, 'formatDataForRequest')
|
.spyOn(formatDataModule, 'formatDataForRequest')
|
||||||
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
|
||||||
|
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -264,14 +257,7 @@ describe('executeScript', () => {
|
|||||||
sasjs0data: 'bar',
|
sasjs0data: 'bar',
|
||||||
_DEBUG: 131
|
_DEBUG: 131
|
||||||
},
|
},
|
||||||
arguments: {
|
version: 2
|
||||||
_contextName: 'test context',
|
|
||||||
_OMITJSONLISTING: true,
|
|
||||||
_OMITJSONLOG: true,
|
|
||||||
_OMITSESSIONRESULTS: false,
|
|
||||||
_OMITTEXTLISTING: true,
|
|
||||||
_OMITTEXTLOG: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mockAuthConfig.access_token
|
mockAuthConfig.access_token
|
||||||
)
|
)
|
||||||
@@ -282,7 +268,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(requestClient, 'post')
|
.spyOn(requestClient, 'post')
|
||||||
.mockImplementation(() => Promise.reject('Test Error'))
|
.mockImplementation(() => Promise.reject('Test Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -302,7 +288,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should immediately return the session when waitForResult is false', async () => {
|
it('should immediately return the session when waitForResult is false', async () => {
|
||||||
const result = await executeScript(
|
const result = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -322,7 +308,12 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should poll for job completion when waitForResult is true', async () => {
|
it('should poll for job completion when waitForResult is true', async () => {
|
||||||
await executeScript(
|
const jobSessionManager: JobSessionManager = {
|
||||||
|
session: mockSession,
|
||||||
|
sessionManager: sessionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -343,7 +334,8 @@ describe('executeScript', () => {
|
|||||||
mockJob,
|
mockJob,
|
||||||
false,
|
false,
|
||||||
mockAuthConfig,
|
mockAuthConfig,
|
||||||
defaultPollOptions
|
defaultPollOptions,
|
||||||
|
jobSessionManager
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,7 +344,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(pollJobStateModule, 'pollJobState')
|
.spyOn(pollJobStateModule, 'pollJobState')
|
||||||
.mockImplementation(() => Promise.reject('Poll Error'))
|
.mockImplementation(() => Promise.reject('Poll Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -378,7 +370,7 @@ describe('executeScript', () => {
|
|||||||
Promise.reject({ response: { data: 'err=5113,' } })
|
Promise.reject({ response: { data: 'err=5113,' } })
|
||||||
)
|
)
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -404,7 +396,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
|
it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -429,7 +421,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not fetch the logs for the job if debug is false', async () => {
|
it('should not fetch the logs for the job if debug is false', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -455,7 +447,7 @@ describe('executeScript', () => {
|
|||||||
Promise.resolve(pollJobStateModule.JobState.Failed)
|
Promise.resolve(pollJobStateModule.JobState.Failed)
|
||||||
)
|
)
|
||||||
|
|
||||||
const error: ComputeJobExecutionError = await executeScript(
|
const error: ComputeJobExecutionError = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -490,7 +482,7 @@ describe('executeScript', () => {
|
|||||||
Promise.resolve(pollJobStateModule.JobState.Error)
|
Promise.resolve(pollJobStateModule.JobState.Error)
|
||||||
)
|
)
|
||||||
|
|
||||||
const error: ComputeJobExecutionError = await executeScript(
|
const error: ComputeJobExecutionError = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -519,7 +511,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should fetch the result if expectWebout is true', async () => {
|
it('should fetch the result if expectWebout is true', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -550,7 +542,7 @@ describe('executeScript', () => {
|
|||||||
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -584,7 +576,7 @@ describe('executeScript', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should clear the session after execution is complete', async () => {
|
it('should clear the session after execution is complete', async () => {
|
||||||
await executeScript(
|
await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
@@ -611,7 +603,7 @@ describe('executeScript', () => {
|
|||||||
.spyOn(sessionManager, 'clearSession')
|
.spyOn(sessionManager, 'clearSession')
|
||||||
.mockImplementation(() => Promise.reject('Clear Session Error'))
|
.mockImplementation(() => Promise.reject('Clear Session Error'))
|
||||||
|
|
||||||
const error = await executeScript(
|
const error = await executeOnComputeApi(
|
||||||
requestClient,
|
requestClient,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
'test',
|
'test',
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils/types'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import { Job, Session } from '../../../types'
|
import { Job, Session, SessionState } from '../../../types'
|
||||||
|
|
||||||
export const mockSession: Session = {
|
export const mockSession: Session = {
|
||||||
id: 's35510n',
|
id: 's35510n',
|
||||||
state: 'idle',
|
state: SessionState.Idle,
|
||||||
|
stateUrl: '',
|
||||||
links: [],
|
links: [],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 1
|
sessionInactiveTimeout: 1
|
||||||
},
|
},
|
||||||
creationTimeStamp: new Date().valueOf().toString()
|
creationTimeStamp: new Date().valueOf().toString(),
|
||||||
|
etag: 'etag-string'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockJob: Job = {
|
export const mockJob: Job = {
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
import { RequestClient } from '../../../request/RequestClient'
|
import { RequestClient } from '../../../request/RequestClient'
|
||||||
import { mockAuthConfig, mockJob } from './mockResponses'
|
import { mockAuthConfig, mockJob } from './mockResponses'
|
||||||
import { pollJobState } from '../pollJobState'
|
import { pollJobState, doPoll, JobState } from '../pollJobState'
|
||||||
import * as getTokensModule from '../../../auth/getTokens'
|
import * as getTokensModule from '../../../auth/getTokens'
|
||||||
import * as saveLogModule from '../saveLog'
|
import * as saveLogModule from '../saveLog'
|
||||||
import * as getFileStreamModule from '../getFileStream'
|
import * as getFileStreamModule from '../getFileStream'
|
||||||
import * as isNodeModule from '../../../utils/isNode'
|
import * as isNodeModule from '../../../utils/isNode'
|
||||||
import * as delayModule from '../../../utils/delay'
|
import * as delayModule from '../../../utils/delay'
|
||||||
import { PollOptions, PollStrategy } from '../../../types'
|
import {
|
||||||
|
PollOptions,
|
||||||
|
PollStrategy,
|
||||||
|
SessionState,
|
||||||
|
JobSessionManager
|
||||||
|
} from '../../../types'
|
||||||
import { WriteStream } from 'fs'
|
import { WriteStream } from 'fs'
|
||||||
|
import { SessionManager } from '../../../SessionManager'
|
||||||
|
import { JobStatePollError } from '../../../types'
|
||||||
|
|
||||||
const baseUrl = 'http://localhost'
|
const baseUrl = 'http://localhost'
|
||||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||||
|
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
||||||
requestClient['httpClient'].defaults.baseURL = baseUrl
|
requestClient['httpClient'].defaults.baseURL = baseUrl
|
||||||
|
|
||||||
const defaultStreamLog = false
|
const defaultStreamLog = false
|
||||||
@@ -276,6 +284,76 @@ describe('pollJobState', () => {
|
|||||||
expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
|
expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should change default poll strategies after completing provided poll options', async () => {
|
||||||
|
const delays: number[] = []
|
||||||
|
|
||||||
|
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
|
||||||
|
delays.push(ms)
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
const customPollOptions: PollOptions = {
|
||||||
|
maxPollCount: 0,
|
||||||
|
pollInterval: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
{ maxPollCount: 202, pollInterval: 300 },
|
||||||
|
{ maxPollCount: 300, pollInterval: 3000 },
|
||||||
|
{ maxPollCount: 500, pollInterval: 30000 },
|
||||||
|
{ maxPollCount: 3400, pollInterval: 60000 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ~200 requests with delay 300ms
|
||||||
|
let request = requests.splice(0, 1)[0]
|
||||||
|
let { maxPollCount, pollInterval } = request
|
||||||
|
|
||||||
|
// should be only one interval because maxPollCount is equal to 0
|
||||||
|
const pollIntervals = [customPollOptions.pollInterval]
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(maxPollCount - 2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~300 requests with delay 3000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
let newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - maxPollCount).fill(pollInterval))
|
||||||
|
pollIntervals.push(...Array(2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~500 requests with delay 30000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
|
||||||
|
let oldAmount = newAmount
|
||||||
|
newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
|
||||||
|
pollIntervals.push(...Array(2).fill(pollInterval))
|
||||||
|
|
||||||
|
// ~3400 requests with delay 60000
|
||||||
|
request = requests.splice(0, 1)[0]
|
||||||
|
|
||||||
|
oldAmount = newAmount
|
||||||
|
newAmount = request.maxPollCount
|
||||||
|
pollInterval = request.pollInterval
|
||||||
|
|
||||||
|
mockSimplePoll(newAmount)
|
||||||
|
|
||||||
|
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
|
||||||
|
|
||||||
|
await pollJobState(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
customPollOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(delays).toEqual(pollIntervals)
|
||||||
|
})
|
||||||
|
|
||||||
it('should throw an error if not valid poll strategies provided', async () => {
|
it('should throw an error if not valid poll strategies provided', async () => {
|
||||||
// INFO: 'maxPollCount' has to be > 0
|
// INFO: 'maxPollCount' has to be > 0
|
||||||
let invalidPollStrategy = {
|
let invalidPollStrategy = {
|
||||||
@@ -353,6 +431,218 @@ describe('pollJobState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('doPoll', () => {
|
||||||
|
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
|
||||||
|
const jobSessionManager: JobSessionManager = {
|
||||||
|
sessionManager,
|
||||||
|
session: {
|
||||||
|
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
||||||
|
state: SessionState.NoState,
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: sessionStateLink,
|
||||||
|
method: 'GET',
|
||||||
|
rel: 'state',
|
||||||
|
type: 'text/plain',
|
||||||
|
uri: sessionStateLink
|
||||||
|
}
|
||||||
|
],
|
||||||
|
attributes: {
|
||||||
|
sessionInactiveTimeout: 900
|
||||||
|
},
|
||||||
|
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
|
||||||
|
stateUrl: '',
|
||||||
|
etag: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check session state on every 10th job state poll', async () => {
|
||||||
|
const mockedGetSessionState = jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: SessionState.Idle,
|
||||||
|
responseStatus: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let getSessionStateCount = 0
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
getSessionStateCount++
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
result:
|
||||||
|
getSessionStateCount < 20 ? JobState.Running : JobState.Completed,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedGetSessionState).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle error while checking session state', async () => {
|
||||||
|
const sessionStateError = 'Error while getting session state.'
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.reject(sessionStateError)
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(mockJob.id, new Error(sessionStateError))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if session state is not healthy', async () => {
|
||||||
|
const filteredSessionStates = Object.values(SessionState).filter(
|
||||||
|
(state) => state !== SessionState.Running && state !== SessionState.Idle
|
||||||
|
)
|
||||||
|
const randomSessionState =
|
||||||
|
filteredSessionStates[
|
||||||
|
Math.floor(Math.random() * filteredSessionStates.length)
|
||||||
|
]
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: randomSessionState,
|
||||||
|
responseStatus: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockedClearSession = jest
|
||||||
|
.spyOn(sessionManager, 'clearSession')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(
|
||||||
|
mockJob.id,
|
||||||
|
new Error(
|
||||||
|
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedClearSession).toHaveBeenCalledWith(
|
||||||
|
jobSessionManager.session.id,
|
||||||
|
mockAuthConfig.access_token
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle throw an error if response status of session state is not 200', async () => {
|
||||||
|
const sessionStateResponseStatus = 500
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: SessionState.Running,
|
||||||
|
responseStatus: sessionStateResponseStatus
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'get').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
result: JobState.Running,
|
||||||
|
etag: 'etag-string',
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockedClearSession = jest
|
||||||
|
.spyOn(sessionManager, 'clearSession')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
doPoll(
|
||||||
|
requestClient,
|
||||||
|
mockJob,
|
||||||
|
JobState.Running,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
defaultPollStrategy,
|
||||||
|
mockAuthConfig,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
jobSessionManager
|
||||||
|
)
|
||||||
|
).rejects.toEqual(
|
||||||
|
new JobStatePollError(
|
||||||
|
mockJob.id,
|
||||||
|
new Error(
|
||||||
|
`Session response status is not 200. Session response status is ${sessionStateResponseStatus}.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockedClearSession).toHaveBeenCalledWith(
|
||||||
|
jobSessionManager.session.id,
|
||||||
|
mockAuthConfig.access_token
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const setupMocks = () => {
|
const setupMocks = () => {
|
||||||
jest.restoreAllMocks()
|
jest.restoreAllMocks()
|
||||||
jest.mock('../../../request/RequestClient')
|
jest.mock('../../../request/RequestClient')
|
||||||
|
|||||||
@@ -69,5 +69,5 @@ const setupMocks = () => {
|
|||||||
.mockImplementation(() => Promise.resolve('Test Log'))
|
.mockImplementation(() => Promise.resolve('Test Log'))
|
||||||
jest
|
jest
|
||||||
.spyOn(writeStreamModule, 'writeStream')
|
.spyOn(writeStreamModule, 'writeStream')
|
||||||
.mockImplementation(() => Promise.resolve())
|
.mockImplementation(() => Promise.resolve(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,22 @@ import {
|
|||||||
describe('writeStream', () => {
|
describe('writeStream', () => {
|
||||||
const filename = 'test.txt'
|
const filename = 'test.txt'
|
||||||
const content = 'test'
|
const content = 'test'
|
||||||
|
|
||||||
let stream: WriteStream
|
let stream: WriteStream
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
stream = await createWriteStream(filename)
|
stream = await createWriteStream(filename)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await deleteFile(filename).catch(() => {}) // Ignore errors if the file doesn't exist
|
||||||
|
stream = await createWriteStream(filename)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deleteFile(filename).catch(() => {}) // Ensure cleanup after test
|
||||||
|
})
|
||||||
|
|
||||||
it('should resolve when the stream is written successfully', async () => {
|
it('should resolve when the stream is written successfully', async () => {
|
||||||
await expect(writeStream(stream, content)).toResolve()
|
await expect(writeStream(stream, content)).toResolve()
|
||||||
await expect(fileExists(filename)).resolves.toEqual(true)
|
await expect(fileExists(filename)).resolves.toEqual(true)
|
||||||
@@ -25,11 +35,30 @@ describe('writeStream', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should reject when the write errors out', async () => {
|
it('should reject when the write errors out', async () => {
|
||||||
|
// Mock implementation of the write method
|
||||||
jest
|
jest
|
||||||
.spyOn(stream, 'write')
|
.spyOn(stream, 'write')
|
||||||
.mockImplementation((_, callback) => callback(new Error('Test Error')))
|
.mockImplementation(
|
||||||
|
(
|
||||||
|
chunk: any,
|
||||||
|
encodingOrCb?:
|
||||||
|
| BufferEncoding
|
||||||
|
| ((error: Error | null | undefined) => void),
|
||||||
|
cb?: (error: Error | null | undefined) => void
|
||||||
|
) => {
|
||||||
|
const callback =
|
||||||
|
typeof encodingOrCb === 'function' ? encodingOrCb : cb
|
||||||
|
if (callback) {
|
||||||
|
callback(new Error('Test Error')) // Simulate an error
|
||||||
|
}
|
||||||
|
return true // Simulate that the write operation was called
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call the writeStream function and catch the error
|
||||||
const error = await writeStream(stream, content).catch((e: any) => e)
|
const error = await writeStream(stream, content).catch((e: any) => e)
|
||||||
|
|
||||||
|
// Assert that the error is correctly handled
|
||||||
expect(error.message).toEqual('Test Error')
|
expect(error.message).toEqual('Test Error')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { WriteStream } from '../../types'
|
|||||||
export const writeStream = async (
|
export const writeStream = async (
|
||||||
stream: WriteStream,
|
stream: WriteStream,
|
||||||
content: string
|
content: string
|
||||||
): Promise<void> =>
|
): Promise<boolean> => {
|
||||||
stream.write(content + '\n', (e: any) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (e) return Promise.reject(e)
|
stream.write(content + '\n', (err: Error | null | undefined) => {
|
||||||
|
if (err) {
|
||||||
return Promise.resolve()
|
reject(err) // Reject on write error
|
||||||
|
} else {
|
||||||
|
resolve(true) // Resolve on successful write
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
|
|||||||
import { openWebPage } from './openWebPage'
|
import { openWebPage } from './openWebPage'
|
||||||
import { verifySas9Login } from './verifySas9Login'
|
import { verifySas9Login } from './verifySas9Login'
|
||||||
import { verifySasViyaLogin } from './verifySasViyaLogin'
|
import { verifySasViyaLogin } from './verifySasViyaLogin'
|
||||||
|
import { isLogInSuccessHeaderPresent } from './'
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
public userName = ''
|
public userName = ''
|
||||||
@@ -14,6 +15,7 @@ export class AuthManager {
|
|||||||
private loginUrl: string
|
private loginUrl: string
|
||||||
private logoutUrl: string
|
private logoutUrl: string
|
||||||
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
|
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private serverUrl: string,
|
private serverUrl: string,
|
||||||
private serverType: ServerType,
|
private serverType: ServerType,
|
||||||
@@ -27,6 +29,8 @@ export class AuthManager {
|
|||||||
: this.serverType === ServerType.SasViya
|
: this.serverType === ServerType.SasViya
|
||||||
? '/SASLogon/logout.do?'
|
? '/SASLogon/logout.do?'
|
||||||
: '/SASLogon/logout'
|
: '/SASLogon/logout'
|
||||||
|
|
||||||
|
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,7 +133,7 @@ export class AuthManager {
|
|||||||
|
|
||||||
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
|
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
|
||||||
|
|
||||||
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
|
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
if (isCredentialsVerifyError(loginResponse)) {
|
if (isCredentialsVerifyError(loginResponse)) {
|
||||||
@@ -214,7 +218,7 @@ export class AuthManager {
|
|||||||
* - a boolean `isLoggedIn`
|
* - a boolean `isLoggedIn`
|
||||||
* - a string `userName`,
|
* - a string `userName`,
|
||||||
* - a string `userFullName` and
|
* - a string `userFullName` and
|
||||||
* - a form `loginForm` if not loggedin.
|
* - a form `loginForm` if not loggedIn.
|
||||||
*/
|
*/
|
||||||
public async checkSession(): Promise<LoginResultInternal> {
|
public async checkSession(): Promise<LoginResultInternal> {
|
||||||
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
|
const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
|
||||||
@@ -381,9 +385,3 @@ const isCredentialsVerifyError = (response: string): boolean =>
|
|||||||
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
|
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
|
||||||
response
|
response
|
||||||
)
|
)
|
||||||
|
|
||||||
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
|
|
||||||
if (serverType === ServerType.Sasjs) return response?.loggedin
|
|
||||||
|
|
||||||
return /You have signed in/gm.test(response)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './AuthManager'
|
export * from './AuthManager'
|
||||||
export * from './isAuthorizeFormRequired'
|
export * from './isAuthorizeFormRequired'
|
||||||
export * from './isLoginRequired'
|
export * from './isLoginRequired'
|
||||||
|
export * from './loginHeader'
|
||||||
|
|||||||
97
src/auth/loginHeader.ts
Normal file
97
src/auth/loginHeader.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { getUserLanguage } from '../utils'
|
||||||
|
|
||||||
|
const enLoginSuccessHeader = 'You have signed in.'
|
||||||
|
|
||||||
|
export const defaultSuccessHeaderKey = 'default'
|
||||||
|
|
||||||
|
// The following headers provided by https://github.com/sasjs/adapter/issues/835#issuecomment-2177818601
|
||||||
|
export const loginSuccessHeaders: { [key: string]: string } = {
|
||||||
|
es: `Ya se ha iniciado la sesi\u00f3n.`,
|
||||||
|
th: `\u0e04\u0e38\u0e13\u0e25\u0e07\u0e0a\u0e37\u0e48\u0e2d\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e41\u0e25\u0e49\u0e27`,
|
||||||
|
ja: `\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002`,
|
||||||
|
nb: `Du har logget deg p\u00e5.`,
|
||||||
|
sl: `Prijavili ste se.`,
|
||||||
|
ar: `\u0644\u0642\u062f \u0642\u0645\u062a `,
|
||||||
|
sk: `Prihl\u00e1sili ste sa.`,
|
||||||
|
zh_HK: `\u60a8\u5df2\u767b\u5165\u3002`,
|
||||||
|
zh_CN: `\u60a8\u5df2\u767b\u5f55\u3002`,
|
||||||
|
it: `L'utente si \u00e8 connesso.`,
|
||||||
|
sv: `Du har loggat in.`,
|
||||||
|
he: `\u05e0\u05db\u05e0\u05e1\u05ea `,
|
||||||
|
nl: `U hebt zich aangemeld.`,
|
||||||
|
pl: `Zosta\u0142e\u015b zalogowany.`,
|
||||||
|
ko: `\ub85c\uadf8\uc778\ud588\uc2b5\ub2c8\ub2e4.`,
|
||||||
|
zh_TW: `\u60a8\u5df2\u767b\u5165\u3002`,
|
||||||
|
tr: `Oturum a\u00e7t\u0131n\u0131z.`,
|
||||||
|
iw: `\u05e0\u05db\u05e0\u05e1\u05ea `,
|
||||||
|
fr: `Vous \u00eates connect\u00e9.`,
|
||||||
|
uk: `\u0412\u0438 \u0432\u0432\u0456\u0439\u0448\u043b\u0438 \u0432 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441.`,
|
||||||
|
pt_BR: `Voc\u00ea se conectou.`,
|
||||||
|
no: `Du har logget deg p\u00e5.`,
|
||||||
|
cs: `Jste p\u0159ihl\u00e1\u0161eni.`,
|
||||||
|
fi: `Olet kirjautunut sis\u00e4\u00e4n.`,
|
||||||
|
ru: `\u0412\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0438 \u0432\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.`,
|
||||||
|
el: `\u0388\u03c7\u03b5\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af.`,
|
||||||
|
hr: `Prijavili ste se.`,
|
||||||
|
da: `Du er logget p\u00e5.`,
|
||||||
|
de: `Sie sind jetzt angemeldet.`,
|
||||||
|
sh: `Prijavljeni ste.`,
|
||||||
|
pt: `Iniciou sess\u00e3o.`,
|
||||||
|
hu: `Bejelentkezett.`,
|
||||||
|
sr: `Prijavljeni ste.`,
|
||||||
|
en: enLoginSuccessHeader,
|
||||||
|
[defaultSuccessHeaderKey]: enLoginSuccessHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides expected login header based on language settings of the browser.
|
||||||
|
* @returns - expected header as a string.
|
||||||
|
*/
|
||||||
|
export const getExpectedLogInSuccessHeader = (): string => {
|
||||||
|
// get default success header
|
||||||
|
let successHeader = loginSuccessHeaders[defaultSuccessHeaderKey]
|
||||||
|
|
||||||
|
// get user language based on language settings of the browser
|
||||||
|
const userLang = getUserLanguage()
|
||||||
|
|
||||||
|
if (userLang) {
|
||||||
|
// get success header on exact match of the language code
|
||||||
|
let userLangSuccessHeader = loginSuccessHeaders[userLang]
|
||||||
|
|
||||||
|
// handle case when there is no exact match of the language code
|
||||||
|
if (!userLangSuccessHeader) {
|
||||||
|
// get all supported language codes
|
||||||
|
const headerLanguages = Object.keys(loginSuccessHeaders)
|
||||||
|
|
||||||
|
// find language code on partial match
|
||||||
|
const headerLanguage = headerLanguages.find((language) =>
|
||||||
|
new RegExp(language, 'i').test(userLang)
|
||||||
|
)
|
||||||
|
|
||||||
|
// reassign success header if partial match was found
|
||||||
|
if (headerLanguage) {
|
||||||
|
successHeader = loginSuccessHeaders[headerLanguage]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
successHeader = userLangSuccessHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return successHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if Login success header is present in the response based on language settings of the browser.
|
||||||
|
* @param serverType - server type.
|
||||||
|
* @param response - response object.
|
||||||
|
* @returns - boolean indicating if Login success header is present.
|
||||||
|
*/
|
||||||
|
export const isLogInSuccessHeaderPresent = (
|
||||||
|
serverType: ServerType,
|
||||||
|
response: any
|
||||||
|
): boolean => {
|
||||||
|
if (serverType === ServerType.Sasjs) return response?.loggedIn
|
||||||
|
|
||||||
|
return new RegExp(getExpectedLogInSuccessHeader(), 'gm').test(response)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
|
import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
import { isNode } from '../utils'
|
import { isNode } from '../utils'
|
||||||
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
import { AuthManager } from '../AuthManager'
|
import { AuthManager } from '../AuthManager'
|
||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import {
|
import {
|
||||||
mockedCurrentUserApi,
|
mockedCurrentUserApi,
|
||||||
mockLoginAuthoriseRequiredResponse,
|
mockLoginAuthoriseRequiredResponse
|
||||||
mockLoginSuccessResponse
|
|
||||||
} from './mockResponses'
|
} from './mockResponses'
|
||||||
import { serialize } from '../../utils'
|
import { serialize } from '../../utils'
|
||||||
import * as openWebPageModule from '../openWebPage'
|
import * as openWebPageModule from '../openWebPage'
|
||||||
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
|
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
|
||||||
import * as verifySas9LoginModule from '../verifySas9Login'
|
import * as verifySas9LoginModule from '../verifySas9Login'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
jest.mock('axios')
|
jest.mock('axios')
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
|
|
||||||
@@ -125,6 +130,7 @@ describe('AuthManager', () => {
|
|||||||
requestClient,
|
requestClient,
|
||||||
authCallback
|
authCallback
|
||||||
)
|
)
|
||||||
|
|
||||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -133,8 +139,9 @@ describe('AuthManager', () => {
|
|||||||
loginForm: { name: 'test' }
|
loginForm: { name: 'test' }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
mockedAxios.post.mockImplementation(() =>
|
mockedAxios.post.mockImplementation(() =>
|
||||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||||
)
|
)
|
||||||
|
|
||||||
const loginResponse = await authManager.logIn(userName, password)
|
const loginResponse = await authManager.logIn(userName, password)
|
||||||
@@ -152,7 +159,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -170,6 +177,7 @@ describe('AuthManager', () => {
|
|||||||
requestClient,
|
requestClient,
|
||||||
authCallback
|
authCallback
|
||||||
)
|
)
|
||||||
|
|
||||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
@@ -178,8 +186,9 @@ describe('AuthManager', () => {
|
|||||||
loginForm: { name: 'test' }
|
loginForm: { name: 'test' }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
mockedAxios.post.mockImplementation(() =>
|
mockedAxios.post.mockImplementation(() =>
|
||||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||||
)
|
)
|
||||||
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
|
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
|
||||||
|
|
||||||
@@ -198,7 +207,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -247,7 +256,7 @@ describe('AuthManager', () => {
|
|||||||
`/SASLogon/login`,
|
`/SASLogon/login`,
|
||||||
loginParams,
|
loginParams,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: '*/*'
|
Accept: '*/*'
|
||||||
@@ -365,7 +374,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual(userName)
|
expect(loginResponse.userName).toEqual(userName)
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -409,7 +418,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual(userName)
|
expect(loginResponse.userName).toEqual(userName)
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -453,7 +462,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual('')
|
expect(loginResponse.userName).toEqual('')
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -497,7 +506,7 @@ describe('AuthManager', () => {
|
|||||||
expect(loginResponse.userName).toEqual('')
|
expect(loginResponse.userName).toEqual('')
|
||||||
|
|
||||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||||
`/SASLogon`,
|
`${serverUrl}/SASLogon`,
|
||||||
'SASLogon',
|
'SASLogon',
|
||||||
{
|
{
|
||||||
width: 500,
|
width: 500,
|
||||||
@@ -530,7 +539,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/identities/users/@currentUser`,
|
`http://test-server.com/identities/users/@currentUser`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -564,7 +573,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/SASStoredProcess`,
|
`http://test-server.com/SASStoredProcess`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -593,7 +602,7 @@ describe('AuthManager', () => {
|
|||||||
1,
|
1,
|
||||||
`http://test-server.com/identities/users/@currentUser`,
|
`http://test-server.com/identities/users/@currentUser`,
|
||||||
{
|
{
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
transformResponse: undefined,
|
transformResponse: undefined,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -612,7 +621,7 @@ describe('AuthManager', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getHeadersJson = {
|
const getHeadersJson = {
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json'
|
Accept: 'application/json'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthConfig } from '@sasjs/utils/types'
|
import { AuthConfig } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { getAccessTokenForViya } from '../getAccessTokenForViya'
|
import { getAccessTokenForViya } from '../getAccessTokenForViya'
|
||||||
|
|||||||
82
src/auth/spec/loginHeader.spec.ts
Normal file
82
src/auth/spec/loginHeader.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import {
|
||||||
|
loginSuccessHeaders,
|
||||||
|
isLogInSuccessHeaderPresent,
|
||||||
|
defaultSuccessHeaderKey
|
||||||
|
} from '../'
|
||||||
|
|
||||||
|
describe('isLogInSuccessHeaderPresent', () => {
|
||||||
|
let languageGetter: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check SASVIYA and SAS9 login success header based on language preferences of the browser', () => {
|
||||||
|
// test SASVIYA server type
|
||||||
|
Object.keys(loginSuccessHeaders).forEach((key) => {
|
||||||
|
languageGetter.mockReturnValue(key)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.SasViya,
|
||||||
|
loginSuccessHeaders[key]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test SAS9 server type
|
||||||
|
Object.keys(loginSuccessHeaders).forEach((key) => {
|
||||||
|
languageGetter.mockReturnValue(key)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sas9, loginSuccessHeaders[key])
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test possible longer language codes
|
||||||
|
const possibleLanguageCodes = [
|
||||||
|
{ short: 'en', long: 'en-US' },
|
||||||
|
{ short: 'fr', long: 'fr-FR' },
|
||||||
|
{ short: 'es', long: 'es-ES' }
|
||||||
|
]
|
||||||
|
|
||||||
|
possibleLanguageCodes.forEach((key) => {
|
||||||
|
const { short, long } = key
|
||||||
|
languageGetter.mockReturnValue(long)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.SasViya,
|
||||||
|
loginSuccessHeaders[short]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
// test falling back to default language code
|
||||||
|
languageGetter.mockReturnValue('WRONG-LANGUAGE')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(
|
||||||
|
ServerType.Sas9,
|
||||||
|
loginSuccessHeaders[defaultSuccessHeaderKey]
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check SASVJS login success header', () => {
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: true })
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: false })
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
expect(isLogInSuccessHeaderPresent(ServerType.Sasjs, undefined)).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { SasAuthResponse } from '@sasjs/utils/types'
|
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 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 = {
|
export const mockAuthResponse: SasAuthResponse = {
|
||||||
access_token: 'acc355',
|
access_token: 'acc355',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthConfig, ServerType } from '@sasjs/utils/types'
|
import { AuthConfig, ServerType } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import { refreshTokensForViya } from '../refreshTokensForViya'
|
import { refreshTokensForViya } from '../refreshTokensForViya'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { verifySas9Login } from '../verifySas9Login'
|
import { verifySas9Login } from '../verifySas9Login'
|
||||||
import * as delayModule from '../../utils/delay'
|
import * as delayModule from '../../utils/delay'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
describe('verifySas9Login', () => {
|
describe('verifySas9Login', () => {
|
||||||
const serverUrl = 'http://test-server.com'
|
const serverUrl = 'http://test-server.com'
|
||||||
@@ -18,7 +19,9 @@ describe('verifySas9Login', () => {
|
|||||||
const popup = {
|
const popup = {
|
||||||
window: {
|
window: {
|
||||||
location: { href: serverUrl + `/SASLogon` },
|
location: { href: serverUrl + `/SASLogon` },
|
||||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
document: {
|
||||||
|
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as unknown as Window
|
} as unknown as Window
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { verifySasViyaLogin } from '../verifySasViyaLogin'
|
import { verifySasViyaLogin } from '../verifySasViyaLogin'
|
||||||
import * as delayModule from '../../utils/delay'
|
import * as delayModule from '../../utils/delay'
|
||||||
|
import { getExpectedLogInSuccessHeader } from '../'
|
||||||
|
|
||||||
describe('verifySasViyaLogin', () => {
|
describe('verifySasViyaLogin', () => {
|
||||||
const serverUrl = 'http://test-server.com'
|
const serverUrl = 'http://test-server.com'
|
||||||
@@ -19,7 +20,9 @@ describe('verifySasViyaLogin', () => {
|
|||||||
const popup = {
|
const popup = {
|
||||||
window: {
|
window: {
|
||||||
location: { href: serverUrl + `/SASLogon` },
|
location: { href: serverUrl + `/SASLogon` },
|
||||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
document: {
|
||||||
|
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} as unknown as Window
|
} as unknown as Window
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delay } from '../utils'
|
import { delay } from '../utils'
|
||||||
|
import { getExpectedLogInSuccessHeader } from './'
|
||||||
|
|
||||||
export async function verifySas9Login(loginPopup: Window): Promise<{
|
export async function verifySas9Login(loginPopup: Window): Promise<{
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
@@ -6,13 +7,17 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
|
|||||||
let isLoggedIn = false
|
let isLoggedIn = false
|
||||||
let startTime = new Date()
|
let startTime = new Date()
|
||||||
let elapsedSeconds = 0
|
let elapsedSeconds = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isLoggedIn =
|
isLoggedIn =
|
||||||
loginPopup.window.location.href.includes('SASLogon') &&
|
loginPopup.window.location.href.includes('SASLogon') &&
|
||||||
loginPopup.window.document.body.innerText.includes('You have signed in.')
|
loginPopup.window.document.body.innerText.includes(
|
||||||
|
getExpectedLogInSuccessHeader()
|
||||||
|
)
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { delay } from '../utils'
|
import { delay } from '../utils'
|
||||||
|
import { getExpectedLogInSuccessHeader } from './'
|
||||||
|
|
||||||
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
@@ -6,23 +7,32 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
|||||||
let isLoggedIn = false
|
let isLoggedIn = false
|
||||||
let startTime = new Date()
|
let startTime = new Date()
|
||||||
let elapsedSeconds = 0
|
let elapsedSeconds = 0
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
|
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isLoggedIn = isLoggedInSASVIYA()
|
isLoggedIn = isLoggedInSASVIYA()
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
let isAuthorized = false
|
let isAuthorized = false
|
||||||
|
|
||||||
startTime = new Date()
|
startTime = new Date()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
await delay(1000)
|
await delay(1000)
|
||||||
|
|
||||||
if (loginPopup.closed) break
|
if (loginPopup.closed) break
|
||||||
|
|
||||||
isAuthorized =
|
isAuthorized =
|
||||||
loginPopup.window.location.href.includes('SASLogon') ||
|
loginPopup.window.location.href.includes('SASLogon') ||
|
||||||
loginPopup.window.document.body?.innerText?.includes(
|
loginPopup.window.document.body?.innerText?.includes(
|
||||||
'You have signed in.'
|
getExpectedLogInSuccessHeader()
|
||||||
)
|
)
|
||||||
|
|
||||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||||
} while (!isAuthorized && elapsedSeconds < 5 * 60)
|
} while (!isAuthorized && elapsedSeconds < 5 * 60)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { convertToCSV } from '../utils/convertToCsv'
|
import { convertToCSV } from '../utils/convertToCsv'
|
||||||
import { isNode } from '../utils'
|
import { isNode } from '../utils'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
|
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
|
||||||
import { splitChunks } from '../utils/splitChunks'
|
import { splitChunks } from '../utils/splitChunks'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { generateFileUploadForm } from '../generateFileUploadForm'
|
import { generateFileUploadForm } from '../generateFileUploadForm'
|
||||||
import { convertToCSV } from '../../utils/convertToCsv'
|
import { convertToCSV } from '../../utils/convertToCsv'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import * as isNodeModule from '../../utils/isNode'
|
import * as isNodeModule from '../../utils/isNode'
|
||||||
|
|
||||||
describe('generateFileUploadForm', () => {
|
describe('generateFileUploadForm', () => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
getValidJson,
|
getValidJson,
|
||||||
parseSasViyaDebugResponse,
|
parseSasViyaDebugResponse,
|
||||||
parseWeboutResponse,
|
parseWeboutResponse
|
||||||
SASJS_LOGS_SEPARATOR
|
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { UploadFile } from '../types/UploadFile'
|
import { UploadFile } from '../types/UploadFile'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
|
|||||||
|
|
||||||
if (config.debug) {
|
if (config.debug) {
|
||||||
requestParams['_omittextlog'] = 'false'
|
requestParams['_omittextlog'] = 'false'
|
||||||
requestParams['_omitsessionresults'] = 'false'
|
requestParams['_omitSessionResults'] = 'false'
|
||||||
|
|
||||||
requestParams['_debug'] = 131
|
requestParams['_debug'] = 131
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import { ErrorResponse } from '../types/errors'
|
import { ErrorResponse } from '../types/errors'
|
||||||
import { convertToCSV, isRelativePath } from '../utils'
|
import { convertToCSV, isRelativePath } from '../utils'
|
||||||
import { BaseJobExecutor } from './JobExecutor'
|
import { BaseJobExecutor } from './JobExecutor'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
ExtraResponseAttributes,
|
ExtraResponseAttributes,
|
||||||
@@ -73,8 +73,10 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
|||||||
/* The NodeFormData object does not set the request header - so, set it */
|
/* The NodeFormData object does not set the request header - so, set it */
|
||||||
const contentType =
|
const contentType =
|
||||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
? `multipart/form-data; boundary=${
|
||||||
: undefined
|
formData.getHeaders()['content-type']
|
||||||
|
}`
|
||||||
|
: 'multipart/form-data'
|
||||||
|
|
||||||
const requestPromise = new Promise((resolve, reject) => {
|
const requestPromise = new Promise((resolve, reject) => {
|
||||||
this.requestClient!.post(
|
this.requestClient!.post(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
import {
|
import {
|
||||||
AuthConfig,
|
AuthConfig,
|
||||||
ExtraResponseAttributes,
|
ExtraResponseAttributes,
|
||||||
@@ -150,8 +150,10 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
/* The NodeFormData object does not set the request header - so, set it */
|
/* The NodeFormData object does not set the request header - so, set it */
|
||||||
const contentType =
|
const contentType =
|
||||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
? `multipart/form-data; boundary=${
|
||||||
: undefined
|
formData.getHeaders()['content-type']
|
||||||
|
}`
|
||||||
|
: 'multipart/form-data'
|
||||||
|
|
||||||
const requestPromise = new Promise((resolve, reject) => {
|
const requestPromise = new Promise((resolve, reject) => {
|
||||||
this.requestClient!.post(
|
this.requestClient!.post(
|
||||||
|
|||||||
@@ -233,7 +233,8 @@ export default class SASjs {
|
|||||||
this.requestClient = new RequestClient(
|
this.requestClient = new RequestClient(
|
||||||
this.sasjsConfig.serverUrl,
|
this.sasjsConfig.serverUrl,
|
||||||
this.sasjsConfig.httpsAgentOptions,
|
this.sasjsConfig.httpsAgentOptions,
|
||||||
this.sasjsConfig.requestHistoryLimit
|
this.sasjsConfig.requestHistoryLimit,
|
||||||
|
this.sasjsConfig.verbose
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
this.requestClient.setConfig(
|
this.requestClient.setConfig(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { RequestClient } from '../../request/RequestClient'
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
import {
|
import {
|
||||||
isRelativePath,
|
isRelativePath,
|
||||||
parseSasViyaDebugResponse,
|
|
||||||
appendExtraResponseAttributes,
|
appendExtraResponseAttributes,
|
||||||
convertToCSV
|
convertToCSV
|
||||||
} from '../../utils'
|
} from '../../utils'
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
import {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosRequestHeaders,
|
||||||
|
AxiosResponse
|
||||||
|
} from 'axios'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { CsrfToken } from '..'
|
import { CsrfToken } from '..'
|
||||||
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
|
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
|
||||||
@@ -10,7 +16,7 @@ import {
|
|||||||
JobExecutionError,
|
JobExecutionError,
|
||||||
CertificateError
|
CertificateError
|
||||||
} from '../types/errors'
|
} from '../types/errors'
|
||||||
import { SASjsRequest } from '../types'
|
import { SASjsRequest, HttpClient, VerboseMode } from '../types'
|
||||||
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
|
import { SAS9AuthError } from '../types/errors/SAS9AuthError'
|
||||||
@@ -20,45 +26,13 @@ import {
|
|||||||
createAxiosInstance
|
createAxiosInstance
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
||||||
|
import { inspect } from 'util'
|
||||||
export interface HttpClient {
|
|
||||||
get<T>(
|
|
||||||
url: string,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
contentType: string,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
post<T>(
|
|
||||||
url: string,
|
|
||||||
data: any,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
contentType: string,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
put<T>(
|
|
||||||
url: string,
|
|
||||||
data: any,
|
|
||||||
accessToken: string | undefined,
|
|
||||||
overrideHeaders: { [key: string]: string | number }
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
delete<T>(
|
|
||||||
url: string,
|
|
||||||
accessToken: string | undefined
|
|
||||||
): Promise<{ result: T; etag: string }>
|
|
||||||
|
|
||||||
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
|
|
||||||
saveLocalStorageToken(accessToken: string, refreshToken: string): void
|
|
||||||
clearCsrfTokens(): void
|
|
||||||
clearLocalStorageTokens(): void
|
|
||||||
getBaseUrl(): string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestClient implements HttpClient {
|
export class RequestClient implements HttpClient {
|
||||||
private requests: SASjsRequest[] = []
|
private requests: SASjsRequest[] = []
|
||||||
private requestsLimit: number = 10
|
private requestsLimit: number = 10
|
||||||
|
private httpInterceptor?: number
|
||||||
|
private verboseMode: VerboseMode = false
|
||||||
|
|
||||||
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
||||||
protected fileUploadCsrfToken: CsrfToken | undefined
|
protected fileUploadCsrfToken: CsrfToken | undefined
|
||||||
@@ -67,10 +41,17 @@ export class RequestClient implements HttpClient {
|
|||||||
constructor(
|
constructor(
|
||||||
protected baseUrl: string,
|
protected baseUrl: string,
|
||||||
httpsAgentOptions?: https.AgentOptions,
|
httpsAgentOptions?: https.AgentOptions,
|
||||||
requestsLimit?: number
|
requestsLimit?: number,
|
||||||
|
verboseMode?: VerboseMode
|
||||||
) {
|
) {
|
||||||
this.createHttpClient(baseUrl, httpsAgentOptions)
|
this.createHttpClient(baseUrl, httpsAgentOptions)
|
||||||
|
|
||||||
if (requestsLimit) this.requestsLimit = requestsLimit
|
if (requestsLimit) this.requestsLimit = requestsLimit
|
||||||
|
|
||||||
|
if (verboseMode) {
|
||||||
|
this.setVerboseMode(verboseMode)
|
||||||
|
this.enableVerboseMode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
|
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
|
||||||
@@ -90,6 +71,7 @@ export class RequestClient implements HttpClient {
|
|||||||
this.csrfToken = { headerName: '', value: '' }
|
this.csrfToken = { headerName: '', value: '' }
|
||||||
this.fileUploadCsrfToken = { headerName: '', value: '' }
|
this.fileUploadCsrfToken = { headerName: '', value: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearLocalStorageTokens() {
|
public clearLocalStorageTokens() {
|
||||||
localStorage.setItem('accessToken', '')
|
localStorage.setItem('accessToken', '')
|
||||||
localStorage.setItem('refreshToken', '')
|
localStorage.setItem('refreshToken', '')
|
||||||
@@ -178,8 +160,9 @@ export class RequestClient implements HttpClient {
|
|||||||
const requestConfig: AxiosRequestConfig = {
|
const requestConfig: AxiosRequestConfig = {
|
||||||
headers,
|
headers,
|
||||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType === 'text/plain') {
|
if (contentType === 'text/plain') {
|
||||||
requestConfig.transformResponse = undefined
|
requestConfig.transformResponse = undefined
|
||||||
}
|
}
|
||||||
@@ -208,6 +191,13 @@ export class RequestClient implements HttpClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param contentType Newer version of Axios is more strict so if you don't
|
||||||
|
* set the contentType to `form data` while sending a FormData object
|
||||||
|
* application/json will be used by default, axios won’t treat it as FormData.
|
||||||
|
* Instead, it serializes data as JSON—resulting in a payload like
|
||||||
|
* {"sometable":{}} and we lose the multipart/form-data formatting.
|
||||||
|
*/
|
||||||
public async post<T>(
|
public async post<T>(
|
||||||
url: string,
|
url: string,
|
||||||
data: any,
|
data: any,
|
||||||
@@ -224,7 +214,7 @@ export class RequestClient implements HttpClient {
|
|||||||
return this.httpClient
|
return this.httpClient
|
||||||
.post<T>(url, data, {
|
.post<T>(url, data, {
|
||||||
headers,
|
headers,
|
||||||
withCredentials: true,
|
withXSRFToken: true,
|
||||||
...additionalSettings
|
...additionalSettings
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -251,7 +241,7 @@ export class RequestClient implements HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.put<T>(url, data, { headers, withCredentials: true })
|
.put<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -270,7 +260,7 @@ export class RequestClient implements HttpClient {
|
|||||||
const headers = this.getHeaders(accessToken, 'application/json')
|
const headers = this.getHeaders(accessToken, 'application/json')
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.delete<T>(url, { headers, withCredentials: true })
|
.delete<T>(url, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -288,7 +278,7 @@ export class RequestClient implements HttpClient {
|
|||||||
const headers = this.getHeaders(accessToken, 'application/json')
|
const headers = this.getHeaders(accessToken, 'application/json')
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.patch<T>(url, data, { headers, withCredentials: true })
|
.patch<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
throwIfError(response)
|
throwIfError(response)
|
||||||
return this.parseResponse<T>(response)
|
return this.parseResponse<T>(response)
|
||||||
@@ -389,6 +379,166 @@ export class RequestClient implements HttpClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds colors to the string.
|
||||||
|
* If verboseMode is set to 'bleached', colors should be disabled
|
||||||
|
* @param str - string to be prettified.
|
||||||
|
* @returns - prettified string
|
||||||
|
*/
|
||||||
|
private prettifyString = (str: any) =>
|
||||||
|
inspect(str, { colors: this.verboseMode !== 'bleached' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats HTTP request/response body.
|
||||||
|
* @param body - HTTP request/response body.
|
||||||
|
* @returns - formatted string.
|
||||||
|
*/
|
||||||
|
private parseInterceptedBody = (body: any) => {
|
||||||
|
if (!body) return ''
|
||||||
|
|
||||||
|
let parsedBody
|
||||||
|
|
||||||
|
// Tries to parse body into JSON object.
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(body)
|
||||||
|
} catch (error) {
|
||||||
|
parsedBody = body
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedBody = body
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyLines = this.prettifyString(parsedBody).split('\n')
|
||||||
|
|
||||||
|
// Leaves first 50 lines
|
||||||
|
if (bodyLines.length > 51) {
|
||||||
|
bodyLines.splice(50)
|
||||||
|
bodyLines.push('...')
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosResponse = (response: AxiosResponse) => {
|
||||||
|
const { status, config, request, data } = response
|
||||||
|
|
||||||
|
const reqHeaders = request?._header ?? 'Not provided\n'
|
||||||
|
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
|
||||||
|
|
||||||
|
const resHeaders = this.formatHeaders(rawHeaders)
|
||||||
|
const parsedResBody = this.parseInterceptedBody(data)
|
||||||
|
|
||||||
|
process.logger?.info(`HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${this.parseInterceptedBody(config.data)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${this.prettifyString(status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||||
|
`)
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAxiosError = (error: AxiosError) => {
|
||||||
|
// Message indicating absent value.
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const { response, request, config } = error
|
||||||
|
|
||||||
|
// Fallback request object that can be safely used to form request summary.
|
||||||
|
// _header is not present in responses with status 1**
|
||||||
|
// rawHeaders are not present in responses with status 1**
|
||||||
|
let fallbackRequest = {
|
||||||
|
_header: `${noValueMessage}\n`,
|
||||||
|
res: { rawHeaders: [noValueMessage] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
fallbackRequest = {
|
||||||
|
_header:
|
||||||
|
request._header ?? request._currentRequest?._header ?? noValueMessage,
|
||||||
|
res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback response object that can be safely used to form response summary.
|
||||||
|
let fallbackResponse = response || {
|
||||||
|
status: noValueMessage,
|
||||||
|
request: fallbackRequest,
|
||||||
|
config: config || {
|
||||||
|
data: noValueMessage,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
data: noValueMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, request: req, data: resData } = fallbackResponse
|
||||||
|
const { _header: reqHeaders, res } = req
|
||||||
|
|
||||||
|
const resHeaders = this.formatHeaders(res.rawHeaders)
|
||||||
|
const parsedResBody = this.parseInterceptedBody(resData)
|
||||||
|
|
||||||
|
process.logger?.info(`HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${this.parseInterceptedBody(config?.data)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${this.prettifyString(status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||||
|
`)
|
||||||
|
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts an array of strings into a single string with the following format:
|
||||||
|
// <headerName>: <headerValue>
|
||||||
|
private formatHeaders = (rawHeaders: string[]): string => {
|
||||||
|
return rawHeaders.reduce((acc, value, i) => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
acc += `${i === 0 ? '' : '\n'}${value}`
|
||||||
|
} else {
|
||||||
|
acc += `: ${value}`
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets verbose mode.
|
||||||
|
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
|
||||||
|
*/
|
||||||
|
public setVerboseMode = (verboseMode: VerboseMode) => {
|
||||||
|
this.verboseMode = verboseMode
|
||||||
|
|
||||||
|
if (this.verboseMode) this.enableVerboseMode()
|
||||||
|
else this.disableVerboseMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns on verbose mode to log every HTTP response.
|
||||||
|
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
|
||||||
|
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||||
|
*/
|
||||||
|
public enableVerboseMode = (
|
||||||
|
successCallBack = this.handleAxiosResponse,
|
||||||
|
errorCallBack = this.handleAxiosError
|
||||||
|
) => {
|
||||||
|
this.httpInterceptor = this.httpClient.interceptors.response.use(
|
||||||
|
successCallBack,
|
||||||
|
errorCallBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns off verbose mode to log every HTTP response.
|
||||||
|
*/
|
||||||
|
public disableVerboseMode = () => {
|
||||||
|
if (this.httpInterceptor) {
|
||||||
|
this.httpClient.interceptors.response.eject(this.httpInterceptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected getHeaders = (
|
protected getHeaders = (
|
||||||
accessToken: string | undefined,
|
accessToken: string | undefined,
|
||||||
contentType: string
|
contentType: string
|
||||||
@@ -487,7 +637,7 @@ export class RequestClient implements HttpClient {
|
|||||||
// Fetching root and creating CSRF cookie
|
// Fetching root and creating CSRF cookie
|
||||||
await this.httpClient
|
await this.httpClient
|
||||||
.get('/', {
|
.get('/', {
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const cookie =
|
const cookie =
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { AxiosRequestConfig } from 'axios'
|
import { AxiosRequestConfig } from 'axios'
|
||||||
import axiosCookieJarSupport from 'axios-cookiejar-support'
|
import { wrapper } from 'axios-cookiejar-support'
|
||||||
import * as tough from 'tough-cookie'
|
import * as tough from 'tough-cookie'
|
||||||
import { prefixMessage } from '@sasjs/utils/error'
|
import { prefixMessage } from '@sasjs/utils/error'
|
||||||
import { RequestClient, throwIfError } from './RequestClient'
|
import { RequestClient, throwIfError } from './RequestClient'
|
||||||
@@ -17,8 +17,8 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
this.httpClient.defaults.validateStatus = (status) =>
|
this.httpClient.defaults.validateStatus = (status) =>
|
||||||
status >= 200 && status < 303
|
status >= 200 && status < 303
|
||||||
|
|
||||||
if (axiosCookieJarSupport) {
|
if (wrapper) {
|
||||||
axiosCookieJarSupport(this.httpClient)
|
wrapper(this.httpClient)
|
||||||
this.httpClient.defaults.jar = new tough.CookieJar()
|
this.httpClient.defaults.jar = new tough.CookieJar()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
const requestConfig: AxiosRequestConfig = {
|
const requestConfig: AxiosRequestConfig = {
|
||||||
headers,
|
headers,
|
||||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||||
withCredentials: true
|
withXSRFToken: true
|
||||||
}
|
}
|
||||||
if (contentType === 'text/plain') {
|
if (contentType === 'text/plain') {
|
||||||
requestConfig.transformResponse = undefined
|
requestConfig.transformResponse = undefined
|
||||||
@@ -103,7 +103,7 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.httpClient
|
return this.httpClient
|
||||||
.post<T>(url, data, { headers, withCredentials: true })
|
.post<T>(url, data, { headers, withXSRFToken: true })
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (response.status === 302) {
|
if (response.status === 302) {
|
||||||
return await this.get(
|
return await this.get(
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { RequestClient } from './RequestClient'
|
import { RequestClient } from './RequestClient'
|
||||||
import { AxiosResponse } from 'axios'
|
import { AxiosResponse } from 'axios'
|
||||||
import { SASJS_LOGS_SEPARATOR } from '../utils'
|
import { SasjsParsedResponse } from '../types'
|
||||||
|
|
||||||
interface SasjsParsedResponse<T> {
|
|
||||||
result: T
|
|
||||||
log: string
|
|
||||||
etag: string
|
|
||||||
status: number
|
|
||||||
printOutput?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specific request client for SASJS.
|
* Specific request client for SASJS.
|
||||||
* Append tokens in headers.
|
* Append tokens in headers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class SasjsRequestClient extends RequestClient {
|
export class SasjsRequestClient extends RequestClient {
|
||||||
getHeaders = (accessToken: string | undefined, contentType: string) => {
|
getHeaders = (accessToken: string | undefined, contentType: string) => {
|
||||||
const headers: any = {}
|
const headers: any = {}
|
||||||
@@ -45,13 +36,30 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
|
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
|
||||||
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
|
const { data } = response
|
||||||
|
const splittedResponse: string[] = data.split(SASJS_LOGS_SEPARATOR)
|
||||||
|
|
||||||
webout = splittedResponse[0]
|
webout = splittedResponse.splice(0, 1)[0]
|
||||||
if (webout !== undefined) parsedResponse = webout
|
if (webout !== undefined) parsedResponse = webout
|
||||||
|
|
||||||
log = splittedResponse[1]
|
// log can contain nested logs
|
||||||
printOutput = splittedResponse[2]
|
const logs = splittedResponse.splice(0, splittedResponse.length - 1)
|
||||||
|
|
||||||
|
// tests if string ends with SASJS_LOGS_SEPARATOR
|
||||||
|
const endingWithLogSepRegExp = new RegExp(`${SASJS_LOGS_SEPARATOR}$`)
|
||||||
|
|
||||||
|
// at this point splittedResponse can contain only one item
|
||||||
|
const lastChunk = splittedResponse[0]
|
||||||
|
|
||||||
|
if (lastChunk) {
|
||||||
|
// if the last chunk doesn't end with SASJS_LOGS_SEPARATOR, then it is a printOutput
|
||||||
|
// else the last chunk is part of the log and has to be joined
|
||||||
|
if (!endingWithLogSepRegExp.test(data)) printOutput = lastChunk
|
||||||
|
else if (logs.length > 1) logs.push(lastChunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// join logs into single log with SASJS_LOGS_SEPARATOR
|
||||||
|
log = logs.join(SASJS_LOGS_SEPARATOR)
|
||||||
} else {
|
} else {
|
||||||
parsedResponse = response.data
|
parsedResponse = response.data
|
||||||
}
|
}
|
||||||
@@ -59,7 +67,7 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
|
|
||||||
const returnResult: SasjsParsedResponse<T> = {
|
const returnResult: SasjsParsedResponse<T> = {
|
||||||
result: parsedResponse as T,
|
result: parsedResponse as T,
|
||||||
log,
|
log: log || '',
|
||||||
etag,
|
etag,
|
||||||
status: response.status
|
status: response.status
|
||||||
}
|
}
|
||||||
@@ -69,3 +77,6 @@ export class SasjsRequestClient extends RequestClient {
|
|||||||
return returnResult
|
return returnResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SASJS_LOGS_SEPARATOR =
|
||||||
|
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
|||||||
177
src/request/spec/SasjsRequestClient.spec.ts
Normal file
177
src/request/spec/SasjsRequestClient.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
|
||||||
|
import { SasjsParsedResponse } from '../../types'
|
||||||
|
import { AxiosRequestHeaders, AxiosResponse } from 'axios'
|
||||||
|
|
||||||
|
describe('SasjsRequestClient', () => {
|
||||||
|
const requestClient = new SasjsRequestClient('')
|
||||||
|
const etag = 'etag'
|
||||||
|
const status = 200
|
||||||
|
|
||||||
|
const webout = `hello`
|
||||||
|
const log = `1 The SAS System Tuesday, 25 July 2023 12:51:00
|
||||||
|
|
||||||
|
|
||||||
|
PROC MIGRATE will preserve current SAS file attributes and is
|
||||||
|
recommended for converting all your SAS libraries from any
|
||||||
|
SAS 8 release to SAS 9. For details and examples, please see
|
||||||
|
http://support.sas.com/rnd/migration/index.html
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
NOTE: SAS initialization used:
|
||||||
|
real time 0.01 seconds
|
||||||
|
cpu time 0.02 seconds
|
||||||
|
|
||||||
|
|
||||||
|
`
|
||||||
|
const printOutput = 'printOutPut'
|
||||||
|
|
||||||
|
describe('parseResponse', () => {})
|
||||||
|
|
||||||
|
it('should parse response with 1 log', () => {
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${log}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with 1 log and printOutput', () => {
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${printOutput}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${log}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status,
|
||||||
|
printOutput: `
|
||||||
|
${printOutput}`
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with nested logs', () => {
|
||||||
|
const logWithNestedLog = `root log start
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
root log end`
|
||||||
|
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${logWithNestedLog}
|
||||||
|
${SASJS_LOGS_SEPARATOR}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${logWithNestedLog}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse response with nested logs and printOutput', () => {
|
||||||
|
const logWithNestedLog = `root log start
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
log with indentation
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${log}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
some SAS code containing ${SASJS_LOGS_SEPARATOR}
|
||||||
|
root log end`
|
||||||
|
|
||||||
|
const response: AxiosResponse<any> = {
|
||||||
|
data: `${webout}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${logWithNestedLog}
|
||||||
|
${SASJS_LOGS_SEPARATOR}
|
||||||
|
${printOutput}`,
|
||||||
|
status,
|
||||||
|
statusText: 'ok',
|
||||||
|
headers: { etag },
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||||
|
result: `${webout}
|
||||||
|
`,
|
||||||
|
log: `
|
||||||
|
${logWithNestedLog}
|
||||||
|
`,
|
||||||
|
etag,
|
||||||
|
status,
|
||||||
|
printOutput: `
|
||||||
|
${printOutput}`
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requestClient['parseResponse'](response)).toEqual(
|
||||||
|
expectedParsedResponse
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SASJS_LOGS_SEPARATOR', () => {
|
||||||
|
it('SASJS_LOGS_SEPARATOR should be hardcoded', () => {
|
||||||
|
expect(SASJS_LOGS_SEPARATOR).toEqual(
|
||||||
|
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
130
src/spec/SAS9ApiClient.spec.ts
Normal file
130
src/spec/SAS9ApiClient.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment node
|
||||||
|
*/
|
||||||
|
import * as https from 'https'
|
||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import { SAS9ApiClient } from '../SAS9ApiClient'
|
||||||
|
import { Sas9RequestClient } from '../request/Sas9RequestClient'
|
||||||
|
|
||||||
|
// Mock the Sas9RequestClient so that we can control its behavior
|
||||||
|
jest.mock('../request/Sas9RequestClient', () => {
|
||||||
|
return {
|
||||||
|
Sas9RequestClient: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(
|
||||||
|
(serverUrl: string, httpsAgentOptions?: https.AgentOptions) => {
|
||||||
|
return {
|
||||||
|
login: jest.fn().mockResolvedValue(undefined),
|
||||||
|
post: jest.fn().mockResolvedValue({ result: 'execution result' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SAS9ApiClient', () => {
|
||||||
|
const serverUrl = 'http://test-server.com'
|
||||||
|
const jobsPath = '/SASStoredProcess/do'
|
||||||
|
let client: SAS9ApiClient
|
||||||
|
let mockRequestClient: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new SAS9ApiClient(serverUrl, jobsPath)
|
||||||
|
// Retrieve the instance of the mocked Sas9RequestClient
|
||||||
|
mockRequestClient = (Sas9RequestClient as jest.Mock).mock.results[0].value
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getConfig', () => {
|
||||||
|
it('should return the correct configuration', () => {
|
||||||
|
const config = client.getConfig()
|
||||||
|
expect(config).toEqual({ serverUrl })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setConfig', () => {
|
||||||
|
it('should update the serverUrl when a valid value is provided', () => {
|
||||||
|
const newUrl = 'http://new-server.com'
|
||||||
|
client.setConfig(newUrl)
|
||||||
|
expect(client.getConfig()).toEqual({ serverUrl: newUrl })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not update the serverUrl when an empty string is provided', () => {
|
||||||
|
const originalConfig = client.getConfig()
|
||||||
|
client.setConfig('')
|
||||||
|
expect(client.getConfig()).toEqual(originalConfig)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeScript', () => {
|
||||||
|
const linesOfCode = ['line1;', 'line2;']
|
||||||
|
const userName = 'testUser'
|
||||||
|
const password = 'testPass'
|
||||||
|
const fixedTimestamp = '1234567890'
|
||||||
|
const expectedFilename = `sasjs-execute-sas9-${fixedTimestamp}.sas`
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Stub generateTimestamp so that we get a consistent filename in our tests.
|
||||||
|
jest
|
||||||
|
.spyOn(require('@sasjs/utils/time'), 'generateTimestamp')
|
||||||
|
.mockReturnValue(fixedTimestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute the script and return the result', async () => {
|
||||||
|
const result = await client.executeScript(linesOfCode, userName, password)
|
||||||
|
|
||||||
|
// Verify that login is called with the correct parameters.
|
||||||
|
expect(mockRequestClient.login).toHaveBeenCalledWith(
|
||||||
|
userName,
|
||||||
|
password,
|
||||||
|
jobsPath
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build the expected stored process URL.
|
||||||
|
const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner`
|
||||||
|
const expectedUrl =
|
||||||
|
`${jobsPath}/?` + '_program=' + codeInjectorPath + '&_debug=log'
|
||||||
|
|
||||||
|
// Verify that post was called with the expected stored process URL.
|
||||||
|
expect(mockRequestClient.post).toHaveBeenCalledWith(
|
||||||
|
expectedUrl,
|
||||||
|
expect.any(NodeFormData),
|
||||||
|
undefined,
|
||||||
|
expect.stringContaining('multipart/form-data; boundary='),
|
||||||
|
expect.objectContaining({
|
||||||
|
'Content-Length': expect.any(Number),
|
||||||
|
'Content-Type': expect.stringContaining(
|
||||||
|
'multipart/form-data; boundary='
|
||||||
|
),
|
||||||
|
Accept: '*/*'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// The method should return the result from the post call.
|
||||||
|
expect(result).toEqual('execution result')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include the force output code in the uploaded form data', async () => {
|
||||||
|
await client.executeScript(linesOfCode, userName, password)
|
||||||
|
// Retrieve the form data passed to post
|
||||||
|
const postCallArgs = (mockRequestClient.post as jest.Mock).mock.calls[0]
|
||||||
|
const formData: NodeFormData = postCallArgs[1]
|
||||||
|
|
||||||
|
// We can inspect the boundary and ensure that the filename was generated correctly.
|
||||||
|
expect(formData.getBoundary()).toBeDefined()
|
||||||
|
|
||||||
|
// The filename is used as the key for the form field.
|
||||||
|
const formDataBuffer = formData.getBuffer().toString()
|
||||||
|
expect(formDataBuffer).toContain(expectedFilename)
|
||||||
|
// Also check that the force output code is appended.
|
||||||
|
expect(formDataBuffer).toContain("put 'Executed sasjs run';")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
231
src/spec/SASjsApiClient.spec.ts
Normal file
231
src/spec/SASjsApiClient.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import {
|
||||||
|
SASjsApiClient,
|
||||||
|
SASjsAuthResponse,
|
||||||
|
ScriptExecutionResult
|
||||||
|
} from '../SASjsApiClient'
|
||||||
|
import { AuthConfig, ServicePackSASjs } from '@sasjs/utils/types'
|
||||||
|
import { ExecutionQuery } from '../types'
|
||||||
|
|
||||||
|
// Create a mock request client with a post method.
|
||||||
|
const mockPost = jest.fn()
|
||||||
|
const mockRequestClient = {
|
||||||
|
post: mockPost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instead of referencing external variables, inline the dummy values in the mock factories.
|
||||||
|
jest.mock('../auth/getTokens', () => ({
|
||||||
|
getTokens: jest.fn().mockResolvedValue({ access_token: 'dummyAccessToken' })
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../auth/getAccessTokenForSasjs', () => ({
|
||||||
|
getAccessTokenForSasjs: jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
} as any)
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('../auth/refreshTokensForSasjs', () => ({
|
||||||
|
refreshTokensForSasjs: jest.fn().mockResolvedValue({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
} as any)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// For deployZipFile, mock the file reading function.
|
||||||
|
jest.mock('@sasjs/utils/file', () => ({
|
||||||
|
createReadStream: jest.fn().mockResolvedValue('readStreamDummy')
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Dummy result to compare against.
|
||||||
|
const dummyResult = {
|
||||||
|
status: 'OK',
|
||||||
|
message: 'Success',
|
||||||
|
streamServiceName: 'service',
|
||||||
|
example: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SASjsApiClient', () => {
|
||||||
|
let client: SASjsApiClient
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = new SASjsApiClient(mockRequestClient as any)
|
||||||
|
mockPost.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deploy', () => {
|
||||||
|
it('should deploy service pack using JSON', async () => {
|
||||||
|
// Arrange: Simulate a successful response.
|
||||||
|
mockPost.mockResolvedValue({ result: dummyResult })
|
||||||
|
|
||||||
|
const dataJson: ServicePackSASjs = {
|
||||||
|
appLoc: '',
|
||||||
|
someOtherProp: 'value'
|
||||||
|
} as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await client.deploy(dataJson, appLoc, authConfig)
|
||||||
|
|
||||||
|
// Assert: Ensure that the JSON gets the appLoc set if not defined.
|
||||||
|
expect(dataJson.appLoc).toBe(appLoc)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/drive/deploy',
|
||||||
|
dataJson,
|
||||||
|
'dummyAccessToken',
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ maxContentLength: Infinity, maxBodyLength: Infinity }
|
||||||
|
)
|
||||||
|
expect(result).toEqual(dummyResult)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deployZipFile', () => {
|
||||||
|
it('should deploy zip file and return the result', async () => {
|
||||||
|
// Arrange: Simulate a successful response.
|
||||||
|
mockPost.mockResolvedValue({ result: dummyResult })
|
||||||
|
const zipFilePath = 'path/to/deploy.zip'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await client.deployZipFile(zipFilePath, authConfig)
|
||||||
|
|
||||||
|
// Assert: Verify that POST is called with multipart form-data.
|
||||||
|
expect(mockPost).toHaveBeenCalled()
|
||||||
|
const callArgs = mockPost.mock.calls[0]
|
||||||
|
expect(callArgs[0]).toBe('SASjsApi/drive/deploy/upload')
|
||||||
|
expect(result).toEqual(dummyResult)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeJob', () => {
|
||||||
|
it('should execute a job with absolute program path', async () => {
|
||||||
|
// Arrange
|
||||||
|
const query: ExecutionQuery = { _program: '/absolute/path' } as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
const authConfig: AuthConfig = { access_token: 'anyToken' } as any
|
||||||
|
mockPost.mockResolvedValue({
|
||||||
|
result: { jobId: 123 },
|
||||||
|
log: 'execution log'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result, log } = await client.executeJob(query, appLoc, authConfig)
|
||||||
|
|
||||||
|
// Assert: The program path should not be prefixed.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/stp/execute',
|
||||||
|
{ _debug: 131, ...query, _program: '/absolute/path' },
|
||||||
|
'anyToken'
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ jobId: 123 })
|
||||||
|
expect(log).toBe('execution log')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute a job with relative program path', async () => {
|
||||||
|
// Arrange
|
||||||
|
const query: ExecutionQuery = { _program: 'relative/path' } as any
|
||||||
|
const appLoc = '/base/appLoc'
|
||||||
|
mockPost.mockResolvedValue({ result: { jobId: 456 }, log: 'another log' })
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { result, log } = await client.executeJob(query, appLoc)
|
||||||
|
|
||||||
|
// Assert: The program path should be prefixed with appLoc.
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/stp/execute',
|
||||||
|
{ _debug: 131, ...query, _program: '/base/appLoc/relative/path' },
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
expect(result).toEqual({ jobId: 456 })
|
||||||
|
expect(log).toBe('another log')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('executeScript', () => {
|
||||||
|
it('should execute a script and return the execution result', async () => {
|
||||||
|
// Arrange
|
||||||
|
const code = 'data _null_; run;'
|
||||||
|
const runTime = 'sas'
|
||||||
|
const authConfig: AuthConfig = {
|
||||||
|
client: 'clientId',
|
||||||
|
secret: 'secret',
|
||||||
|
access_token: 'token',
|
||||||
|
refresh_token: 'refresh'
|
||||||
|
}
|
||||||
|
const responsePayload = {
|
||||||
|
log: 'log output',
|
||||||
|
printOutput: 'print output',
|
||||||
|
result: 'web output'
|
||||||
|
}
|
||||||
|
mockPost.mockResolvedValue(responsePayload)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result: ScriptExecutionResult = await client.executeScript(
|
||||||
|
code,
|
||||||
|
runTime,
|
||||||
|
authConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'SASjsApi/code/execute',
|
||||||
|
{ code, runTime },
|
||||||
|
'dummyAccessToken'
|
||||||
|
)
|
||||||
|
expect(result.log).toBe('log output')
|
||||||
|
expect(result.printOutput).toBe('print output')
|
||||||
|
expect(result.webout).toBe('web output')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error with a prefixed message when POST fails', async () => {
|
||||||
|
// Arrange
|
||||||
|
const code = 'data _null_; run;'
|
||||||
|
const errorMessage = 'Network Error'
|
||||||
|
mockPost.mockRejectedValue(new Error(errorMessage))
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await expect(client.executeScript(code)).rejects.toThrow(
|
||||||
|
/Error while sending POST request to execute code/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAccessToken', () => {
|
||||||
|
it('should exchange auth code for access token', async () => {
|
||||||
|
// Act
|
||||||
|
const result = await client.getAccessToken('clientId', 'authCode123')
|
||||||
|
|
||||||
|
// Assert: The result should match the dummy auth response.
|
||||||
|
expect(result).toEqual({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('refreshTokens', () => {
|
||||||
|
it('should exchange refresh token for new tokens', async () => {
|
||||||
|
// Act
|
||||||
|
const result = await client.refreshTokens('refreshToken123')
|
||||||
|
|
||||||
|
// Assert: The result should match the dummy auth response.
|
||||||
|
expect(result).toEqual({
|
||||||
|
access_token: 'newAccessToken',
|
||||||
|
refresh_token: 'newRefreshToken'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,36 +5,39 @@ import { app, mockedAuthResponse } from './SAS_server_app'
|
|||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
import SASjs from '../SASjs'
|
import SASjs from '../SASjs'
|
||||||
import * as axiosModules from '../utils/createAxiosInstance'
|
import * as axiosModules from '../utils/createAxiosInstance'
|
||||||
|
import axios, { AxiosRequestHeaders } from 'axios'
|
||||||
import {
|
import {
|
||||||
LoginRequiredError,
|
LoginRequiredError,
|
||||||
AuthorizeError,
|
AuthorizeError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
InternalServerError
|
InternalServerError,
|
||||||
} from '../types/errors'
|
VerboseMode
|
||||||
|
} from '../types'
|
||||||
import { RequestClient } from '../request/RequestClient'
|
import { RequestClient } from '../request/RequestClient'
|
||||||
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
|
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
|
||||||
|
import { AxiosResponse, AxiosError } from 'axios'
|
||||||
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
|
import * as UtilsModule from 'util'
|
||||||
|
|
||||||
const axiosActual = jest.requireActual('axios')
|
const axiosActual = jest.requireActual('axios')
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(axiosModules, 'createAxiosInstance')
|
.spyOn(axiosModules, 'createAxiosInstance')
|
||||||
.mockImplementation((baseURL: string, httpsAgent?: https.Agent) =>
|
.mockImplementation((baseURL: string, httpsAgent?: https.Agent) =>
|
||||||
axiosActual.create({ baseURL, httpsAgent })
|
axiosActual.create({ baseURL, httpsAgent, withXSRFToken: true })
|
||||||
)
|
)
|
||||||
|
|
||||||
|
jest.mock('util', () => {
|
||||||
|
const actualUtil = jest.requireActual('util')
|
||||||
|
return {
|
||||||
|
...actualUtil,
|
||||||
|
inspect: jest.fn(actualUtil.inspect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const PORT = 8000
|
const PORT = 8000
|
||||||
const SERVER_URL = `https://localhost:${PORT}/`
|
const SERVER_URL = `https://localhost:${PORT}/`
|
||||||
|
|
||||||
const ERROR_MESSAGES = {
|
|
||||||
selfSigned: 'self signed certificate',
|
|
||||||
CCA: 'unable to verify the first certificate'
|
|
||||||
}
|
|
||||||
|
|
||||||
const incorrectAuthCodeErr = {
|
|
||||||
error: 'unauthorized',
|
|
||||||
error_description: 'Bad credentials'
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RequestClient', () => {
|
describe('RequestClient', () => {
|
||||||
let server: http.Server
|
let server: http.Server
|
||||||
|
|
||||||
@@ -80,6 +83,413 @@ describe('RequestClient', () => {
|
|||||||
expect(rejectionErrorMessage).toEqual(expectedError.message)
|
expect(rejectionErrorMessage).toEqual(expectedError.message)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('defaultInterceptionCallBacks for successful requests and failed requests', () => {
|
||||||
|
const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1
|
||||||
|
Accept: application/json
|
||||||
|
Content-Type: application/json
|
||||||
|
User-Agent: axios/0.27.2
|
||||||
|
Content-Length: 334
|
||||||
|
host: sas.server.io
|
||||||
|
Connection: close
|
||||||
|
`
|
||||||
|
const reqData = `{
|
||||||
|
name: 'test_job',
|
||||||
|
description: 'Powered by SASjs',
|
||||||
|
code: ['test_code'],
|
||||||
|
variables: {
|
||||||
|
SYS_JES_JOB_URI: '',
|
||||||
|
_program: '/Public/sasjs/jobs/jobs/test_job'
|
||||||
|
},
|
||||||
|
arguments: {
|
||||||
|
_contextName: 'SAS Job Execution compute context',
|
||||||
|
_OMITJSONLISTING: true,
|
||||||
|
_OMITJSONLOG: true,
|
||||||
|
_omitSessionResults: true,
|
||||||
|
_OMITTEXTLISTING: true,
|
||||||
|
_OMITTEXTLOG: true
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const resHeaders = ['content-type', 'application/json']
|
||||||
|
const resData = {
|
||||||
|
id: 'id_string',
|
||||||
|
name: 'name_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
createdBy: 'createdBy_string',
|
||||||
|
code: 'TEST CODE',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'state',
|
||||||
|
href: 'state_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'method_string',
|
||||||
|
rel: 'self',
|
||||||
|
href: 'self_href_string',
|
||||||
|
uri: 'uri_string',
|
||||||
|
type: 'type_string'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
results: { '_webout.json': '_webout.json_string' },
|
||||||
|
logStatistics: {
|
||||||
|
lineCount: 1,
|
||||||
|
modifiedTimeStamp: 'modifiedTimeStamp_string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beforeAll(() => {
|
||||||
|
;(process as any).logger = new Logger(LogLevel.Off)
|
||||||
|
jest.spyOn((process as any).logger, 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 1**', () => {
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${noValueMessage}
|
||||||
|
\n${requestClient['parseInterceptedBody'](noValueMessage)}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 2**', () => {
|
||||||
|
const status = getRandomStatus([
|
||||||
|
200, 201, 202, 203, 204, 205, 206, 207, 208, 226
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosResponse'](mockedResponse)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 3**', () => {
|
||||||
|
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
|
||||||
|
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const noValueMessage = 'Not provided'
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${noValueMessage}
|
||||||
|
\n${requestClient['parseInterceptedBody'](noValueMessage)}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 4**', () => {
|
||||||
|
const spyIsAxiosError = jest
|
||||||
|
.spyOn(axios, 'isAxiosError')
|
||||||
|
.mockImplementation(() => true)
|
||||||
|
|
||||||
|
const status = getRandomStatus([
|
||||||
|
400, 401, 402, 403, 404, 407, 408, 409, 410, 411, 412, 413, 414, 415,
|
||||||
|
416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: mockedResponse
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
|
||||||
|
spyIsAxiosError.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should log parsed response with status 5**', () => {
|
||||||
|
const spyIsAxiosError = jest
|
||||||
|
.spyOn(axios, 'isAxiosError')
|
||||||
|
.mockImplementation(() => true)
|
||||||
|
|
||||||
|
const status = getRandomStatus([
|
||||||
|
500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511
|
||||||
|
])
|
||||||
|
|
||||||
|
const mockedResponse: AxiosResponse = {
|
||||||
|
data: resData,
|
||||||
|
status,
|
||||||
|
statusText: '',
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
data: reqData,
|
||||||
|
headers: {} as AxiosRequestHeaders
|
||||||
|
},
|
||||||
|
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||||
|
}
|
||||||
|
const mockedAxiosError = {
|
||||||
|
config: {
|
||||||
|
data: reqData
|
||||||
|
},
|
||||||
|
request: {
|
||||||
|
_currentRequest: {
|
||||||
|
_header: reqHeaders
|
||||||
|
}
|
||||||
|
},
|
||||||
|
response: mockedResponse
|
||||||
|
} as AxiosError
|
||||||
|
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient['handleAxiosError'](mockedAxiosError)
|
||||||
|
|
||||||
|
const expectedLog = `HTTP Request (first 50 lines):
|
||||||
|
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||||
|
|
||||||
|
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||||
|
|
||||||
|
HTTP Response (first 50 lines):
|
||||||
|
${resHeaders[0]}: ${resHeaders[1]}${
|
||||||
|
requestClient['parseInterceptedBody'](resData)
|
||||||
|
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||||
|
|
||||||
|
spyIsAxiosError.mockReset()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enableVerboseMode', () => {
|
||||||
|
it('should add defaultInterceptionCallBack functions to response interceptors', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'use'
|
||||||
|
)
|
||||||
|
|
||||||
|
requestClient.enableVerboseMode()
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(
|
||||||
|
requestClient['handleAxiosResponse'],
|
||||||
|
requestClient['handleAxiosError']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add callback functions to response interceptors', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'use'
|
||||||
|
)
|
||||||
|
|
||||||
|
const successCallback = (response: AxiosResponse) => {
|
||||||
|
console.log('success')
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
const failureCallback = (response: AxiosError) => {
|
||||||
|
console.log('failure')
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
requestClient.enableVerboseMode(successCallback, failureCallback)
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(
|
||||||
|
successCallback,
|
||||||
|
failureCallback
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setVerboseMode', () => {
|
||||||
|
it(`should set verbose mode`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
let verbose: VerboseMode = false
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
|
||||||
|
verbose = true
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
|
||||||
|
verbose = 'bleached'
|
||||||
|
requestClient.setVerboseMode(verbose)
|
||||||
|
|
||||||
|
expect(requestClient['verboseMode']).toEqual(verbose)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('prettifyString', () => {
|
||||||
|
const inspectMock = UtilsModule.inspect as unknown as jest.Mock
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset the mock before each test to ensure a clean slate
|
||||||
|
inspectMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`should call inspect without colors when verbose mode is set to 'bleached'`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient.setVerboseMode('bleached')
|
||||||
|
|
||||||
|
const testStr = JSON.stringify({ test: 'test' })
|
||||||
|
requestClient['prettifyString'](testStr)
|
||||||
|
|
||||||
|
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||||
|
colors: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`should call inspect with colors when verbose mode is set to true`, () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
requestClient.setVerboseMode(true)
|
||||||
|
|
||||||
|
const testStr = JSON.stringify({ test: 'test' })
|
||||||
|
requestClient['prettifyString'](testStr)
|
||||||
|
|
||||||
|
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||||
|
colors: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disableVerboseMode', () => {
|
||||||
|
it('should eject interceptor', () => {
|
||||||
|
const requestClient = new RequestClient('')
|
||||||
|
|
||||||
|
const interceptorSpy = jest.spyOn(
|
||||||
|
requestClient['httpClient'].interceptors.response,
|
||||||
|
'eject'
|
||||||
|
)
|
||||||
|
|
||||||
|
const interceptorId = 100
|
||||||
|
|
||||||
|
requestClient['httpInterceptor'] = interceptorId
|
||||||
|
requestClient.disableVerboseMode()
|
||||||
|
|
||||||
|
expect(interceptorSpy).toHaveBeenCalledWith(interceptorId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('handleError', () => {
|
describe('handleError', () => {
|
||||||
const requestClient = new RequestClient('https://localhost:8009')
|
const requestClient = new RequestClient('https://localhost:8009')
|
||||||
const randomError = 'some error'
|
const randomError = 'some error'
|
||||||
@@ -293,3 +703,11 @@ const createCertificate = async (): Promise<pem.CertificateCreationResult> => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a random status code.
|
||||||
|
* @param statuses - an array of available statuses.
|
||||||
|
* @returns - random item from an array of statuses.
|
||||||
|
*/
|
||||||
|
const getRandomStatus = (statuses: number[]) =>
|
||||||
|
statuses[Math.floor(Math.random() * statuses.length)]
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import express = require('express')
|
import express = require('express')
|
||||||
|
import cors from 'cors'
|
||||||
|
|
||||||
export const app = express()
|
export const app = express()
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: 'http://localhost', // Allow requests only from this origin
|
||||||
|
credentials: true // Allow credentials (cookies, auth headers, etc.)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const mockedAuthResponse = {
|
export const mockedAuthResponse = {
|
||||||
access_token: 'access_token',
|
access_token: 'access_token',
|
||||||
token_type: 'bearer',
|
token_type: 'bearer',
|
||||||
@@ -12,11 +20,11 @@ export const mockedAuthResponse = {
|
|||||||
jti: 'jti'
|
jti: 'jti'
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', function (req: any, res: any) {
|
app.get('/', (req: any, res: any) => {
|
||||||
res.send('Hello World')
|
res.send('Hello World')
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/SASLogon/oauth/token', function (req: any, res: any) {
|
app.post('/SASLogon/oauth/token', (req: any, res: any) => {
|
||||||
let valid = true
|
let valid = true
|
||||||
|
|
||||||
// capture the encoded form data
|
// capture the encoded form data
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { RequestClient } from '../request/RequestClient'
|
|||||||
import * as dotenv from 'dotenv'
|
import * as dotenv from 'dotenv'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||||
import { Session, Context } from '../types'
|
import { Session, SessionState, Context } from '../types'
|
||||||
|
|
||||||
jest.mock('axios')
|
jest.mock('axios')
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||||
@@ -11,21 +11,34 @@ const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
|||||||
|
|
||||||
describe('SessionManager', () => {
|
describe('SessionManager', () => {
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
process.env.SERVER_URL = 'https://server.com'
|
||||||
|
|
||||||
const sessionManager = new SessionManager(
|
const sessionManager = new SessionManager(
|
||||||
process.env.SERVER_URL as string,
|
process.env.SERVER_URL as string,
|
||||||
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
||||||
requestClient
|
requestClient
|
||||||
)
|
)
|
||||||
|
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
|
||||||
|
const sessionEtag = 'etag-string'
|
||||||
|
|
||||||
const getMockSession = () => ({
|
const getMockSession = (): Session => ({
|
||||||
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
id: ['id', new Date().getTime(), Math.random()].join('-'),
|
||||||
state: '',
|
state: SessionState.NoState,
|
||||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
links: [
|
||||||
|
{
|
||||||
|
href: sessionStateLink,
|
||||||
|
method: 'GET',
|
||||||
|
rel: 'state',
|
||||||
|
type: 'text/plain',
|
||||||
|
uri: sessionStateLink
|
||||||
|
}
|
||||||
|
],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 900
|
sessionInactiveTimeout: 900
|
||||||
},
|
},
|
||||||
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`
|
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
|
||||||
|
stateUrl: sessionStateLink,
|
||||||
|
etag: sessionEtag
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -89,19 +102,21 @@ describe('SessionManager', () => {
|
|||||||
describe('waitForSession', () => {
|
describe('waitForSession', () => {
|
||||||
const session: Session = {
|
const session: Session = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
state: '',
|
state: SessionState.NoState,
|
||||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: 0
|
sessionInactiveTimeout: 0
|
||||||
},
|
},
|
||||||
creationTimeStamp: ''
|
creationTimeStamp: '',
|
||||||
|
stateUrl: sessionStateLink,
|
||||||
|
etag: sessionEtag
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
;(process as any).logger = new Logger(LogLevel.Off)
|
;(process as any).logger = new Logger(LogLevel.Off)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should reject with NoSessionStateError if SAS server did not provide session state', async () => {
|
it('should log http response code and session state if SAS server did not provide session state', async () => {
|
||||||
let requestAttempt = 0
|
let requestAttempt = 0
|
||||||
const requestAttemptLimit = 10
|
const requestAttemptLimit = 10
|
||||||
const sessionState = 'idle'
|
const sessionState = 'idle'
|
||||||
@@ -124,15 +139,17 @@ describe('SessionManager', () => {
|
|||||||
sessionManager['waitForSession'](session, null, 'access_token')
|
sessionManager['waitForSession'](session, null, 'access_token')
|
||||||
).resolves.toEqual(sessionState)
|
).resolves.toEqual(sessionState)
|
||||||
|
|
||||||
|
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
|
||||||
|
|
||||||
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
||||||
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
1,
|
1,
|
||||||
`Polling: ${process.env.SERVER_URL}`
|
`Polling: ${sessionStateUrl}`
|
||||||
)
|
)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
`Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}`
|
`Could not get session state. Server responded with 304 whilst checking state: ${sessionStateUrl}`
|
||||||
)
|
)
|
||||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||||
3,
|
3,
|
||||||
@@ -142,7 +159,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
it('should throw an error if there is no session link', async () => {
|
it('should throw an error if there is no session link', async () => {
|
||||||
const customSession = JSON.parse(JSON.stringify(session))
|
const customSession = JSON.parse(JSON.stringify(session))
|
||||||
customSession.links = []
|
customSession.stateUrl = ''
|
||||||
|
|
||||||
mockedAxios.get.mockImplementation(() =>
|
mockedAxios.get.mockImplementation(() =>
|
||||||
Promise.resolve({ data: customSession.state, status: 200 })
|
Promise.resolve({ data: customSession.state, status: 200 })
|
||||||
@@ -156,6 +173,7 @@ describe('SessionManager', () => {
|
|||||||
it('should throw an error if could not get session state', async () => {
|
it('should throw an error if could not get session state', async () => {
|
||||||
const gettingSessionStatus = 500
|
const gettingSessionStatus = 500
|
||||||
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
|
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
|
||||||
|
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
|
||||||
|
|
||||||
mockedAxios.get.mockImplementation(() =>
|
mockedAxios.get.mockImplementation(() =>
|
||||||
Promise.reject({
|
Promise.reject({
|
||||||
@@ -168,7 +186,7 @@ describe('SessionManager', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${process.env.SERVER_URL}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
|
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${sessionStateUrl}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sessionManager['waitForSession'](session, null, 'access_token')
|
sessionManager['waitForSession'](session, null, 'access_token')
|
||||||
@@ -427,4 +445,45 @@ describe('SessionManager', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createAndWaitForSession', () => {
|
||||||
|
it('should create session with etag and stateUrl', async () => {
|
||||||
|
const etag = sessionEtag
|
||||||
|
const customSession: any = getMockSession()
|
||||||
|
delete customSession.etag
|
||||||
|
delete customSession.stateUrl
|
||||||
|
|
||||||
|
jest.spyOn(requestClient, 'post').mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
result: customSession,
|
||||||
|
etag
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'setCurrentContext')
|
||||||
|
.mockImplementation(() => Promise.resolve())
|
||||||
|
|
||||||
|
sessionManager['currentContext'] = {
|
||||||
|
name: 'context name',
|
||||||
|
id: 'string',
|
||||||
|
createdBy: 'string',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(sessionManager as any, 'getSessionState')
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve({ result: SessionState.Idle, responseStatus: 200 })
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedSession = await sessionManager['createAndWaitForSession']()
|
||||||
|
|
||||||
|
expect(customSession.id).toEqual(expectedSession.id)
|
||||||
|
expect(
|
||||||
|
customSession.links.find((l: any) => l.rel === 'state').href
|
||||||
|
).toEqual(expectedSession.stateUrl)
|
||||||
|
expect(expectedSession.etag).toEqual(etag)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
33
src/types/FileResource.ts
Normal file
33
src/types/FileResource.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface FileResource {
|
||||||
|
creationTimeStamp: string
|
||||||
|
modifiedTimeStamp: string
|
||||||
|
createdBy: string
|
||||||
|
modifiedBy: string
|
||||||
|
id: string
|
||||||
|
properties: Properties
|
||||||
|
contentDisposition: string
|
||||||
|
contentType: string
|
||||||
|
encoding: string
|
||||||
|
links: Link[]
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
searchable: boolean
|
||||||
|
fileStatus: string
|
||||||
|
fileVersion: number
|
||||||
|
typeDefName: string
|
||||||
|
version: number
|
||||||
|
virusDetected: boolean
|
||||||
|
urlDetected: boolean
|
||||||
|
quarantine: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Link {
|
||||||
|
method: string
|
||||||
|
rel: string
|
||||||
|
href: string
|
||||||
|
uri: string
|
||||||
|
type?: string
|
||||||
|
responseType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Properties {}
|
||||||
55
src/types/RequestClient.ts
Normal file
55
src/types/RequestClient.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { CsrfToken } from '..'
|
||||||
|
|
||||||
|
export interface HttpClient {
|
||||||
|
get<T>(
|
||||||
|
url: string,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
contentType: string,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
post<T>(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
contentType: string,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
put<T>(
|
||||||
|
url: string,
|
||||||
|
data: any,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
overrideHeaders: { [key: string]: string | number }
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
delete<T>(
|
||||||
|
url: string,
|
||||||
|
accessToken: string | undefined
|
||||||
|
): Promise<{ result: T; etag: string }>
|
||||||
|
|
||||||
|
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
|
||||||
|
saveLocalStorageToken(accessToken: string, refreshToken: string): void
|
||||||
|
clearCsrfTokens(): void
|
||||||
|
clearLocalStorageTokens(): void
|
||||||
|
getBaseUrl(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SASjsRequest {
|
||||||
|
serviceLink: string
|
||||||
|
timestamp: Date
|
||||||
|
sourceCode: string
|
||||||
|
generatedCode: string
|
||||||
|
logFile: string
|
||||||
|
SASWORK: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SasjsParsedResponse<T> {
|
||||||
|
result: T
|
||||||
|
log: string
|
||||||
|
etag: string
|
||||||
|
status: number
|
||||||
|
printOutput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerboseMode = boolean | 'bleached'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import { ServerType } from '@sasjs/utils/types'
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { VerboseMode } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specifies the configuration for the SASjs instance - eg where and how to
|
* Specifies the configuration for the SASjs instance - eg where and how to
|
||||||
@@ -45,6 +46,10 @@ export class SASjsConfig {
|
|||||||
* Set to `true` to enable additional debugging.
|
* Set to `true` to enable additional debugging.
|
||||||
*/
|
*/
|
||||||
debug: boolean = true
|
debug: boolean = true
|
||||||
|
/**
|
||||||
|
* Set to `true` to enable verbose mode that will log a summary of every HTTP response.
|
||||||
|
*/
|
||||||
|
verbose?: VerboseMode = true
|
||||||
/**
|
/**
|
||||||
* The name of the compute context to use when calling the Viya services directly.
|
* The name of the compute context to use when calling the Viya services directly.
|
||||||
* Example value: 'SAS Job Execution compute context'
|
* Example value: 'SAS Job Execution compute context'
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Represents a SASjs request, its response and logs.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export interface SASjsRequest {
|
|
||||||
serviceLink: string
|
|
||||||
timestamp: Date
|
|
||||||
sourceCode: string
|
|
||||||
generatedCode: string
|
|
||||||
logFile: string
|
|
||||||
SASWORK: any
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
import { Link } from './Link'
|
import { Link } from './Link'
|
||||||
|
import { SessionManager } from '../SessionManager'
|
||||||
|
|
||||||
|
export enum SessionState {
|
||||||
|
Completed = 'completed',
|
||||||
|
Running = 'running',
|
||||||
|
Pending = 'pending',
|
||||||
|
Idle = 'idle',
|
||||||
|
Unavailable = 'unavailable',
|
||||||
|
NoState = '',
|
||||||
|
Failed = 'failed',
|
||||||
|
Error = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
state: string
|
state: SessionState
|
||||||
|
stateUrl: string
|
||||||
links: Link[]
|
links: Link[]
|
||||||
attributes: {
|
attributes: {
|
||||||
sessionInactiveTimeout: number
|
sessionInactiveTimeout: number
|
||||||
}
|
}
|
||||||
creationTimeStamp: string
|
creationTimeStamp: string
|
||||||
|
etag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionVariable {
|
export interface SessionVariable {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobSessionManager {
|
||||||
|
session: Session
|
||||||
|
sessionManager: SessionManager
|
||||||
|
}
|
||||||
|
|||||||
28
src/types/Tables.spec.ts
Normal file
28
src/types/Tables.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SASjs from '../SASjs'
|
||||||
|
|
||||||
|
describe('Tables - basic coverage', () => {
|
||||||
|
const adapter = new SASjs()
|
||||||
|
|
||||||
|
it('should throw an error if first argument is not an array', () => {
|
||||||
|
expect(() => adapter.Tables({}, 'test')).toThrow('First argument')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if second argument is not a string', () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => adapter.Tables([], 1234)).toThrow('Second argument')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if macro name ends with a number', () => {
|
||||||
|
expect(() => adapter.Tables([], 'test1')).toThrow('number at the end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if no arguments are passed', () => {
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => adapter.Tables()).toThrow('Missing arguments')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create Tables class successfully with _tables property', () => {
|
||||||
|
const tables = adapter.Tables([], 'test')
|
||||||
|
expect(tables).toHaveProperty('_tables')
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/types/Tables.ts
Normal file
29
src/types/Tables.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { ArgumentError } from './errors'
|
||||||
|
|
||||||
|
export class Tables {
|
||||||
|
_tables: { [macroName: string]: Record<string, any> }
|
||||||
|
|
||||||
|
constructor(table: Record<string, any>, macroName: string) {
|
||||||
|
this._tables = {}
|
||||||
|
|
||||||
|
this.add(table, macroName)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(table: Record<string, any> | null, macroName: string) {
|
||||||
|
if (table && macroName) {
|
||||||
|
if (!(table instanceof Array)) {
|
||||||
|
throw new ArgumentError('First argument must be array')
|
||||||
|
}
|
||||||
|
if (typeof macroName !== 'string') {
|
||||||
|
throw new ArgumentError('Second argument must be string')
|
||||||
|
}
|
||||||
|
if (!isNaN(Number(macroName[macroName.length - 1]))) {
|
||||||
|
throw new ArgumentError('Macro name cannot have number at the end')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ArgumentError('Missing arguments')
|
||||||
|
}
|
||||||
|
|
||||||
|
this._tables[macroName] = table
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
export interface WriteStream {
|
import { WriteStream as FsWriteStream } from 'fs'
|
||||||
write: (content: string, callback: (err?: Error) => any) => void
|
|
||||||
path: string
|
export interface WriteStream extends FsWriteStream {
|
||||||
|
write(
|
||||||
|
chunk: any,
|
||||||
|
encoding?: BufferEncoding | ((error: Error | null | undefined) => void),
|
||||||
|
cb?: (error: Error | null | undefined) => void
|
||||||
|
): boolean
|
||||||
|
path: string | Buffer
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/types/errors/ArgumentError.ts
Normal file
7
src/types/errors/ArgumentError.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export class ArgumentError extends Error {
|
||||||
|
constructor(public message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ArgumentError'
|
||||||
|
Object.setPrototypeOf(this, ArgumentError.prototype)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './ArgumentError'
|
||||||
export * from './AuthorizeError'
|
export * from './AuthorizeError'
|
||||||
export * from './CertificateError'
|
export * from './CertificateError'
|
||||||
export * from './ComputeJobExecutionError'
|
export * from './ComputeJobExecutionError'
|
||||||
|
|||||||
30
src/types/errors/spec/SAS9AuthError.spec.ts
Normal file
30
src/types/errors/spec/SAS9AuthError.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { SAS9AuthError } from '../SAS9AuthError'
|
||||||
|
|
||||||
|
describe('SAS9AuthError', () => {
|
||||||
|
it('should have the correct error message', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error.message).toBe(
|
||||||
|
'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have the correct error name', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error.name).toBe('AuthorizeError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be an instance of SAS9AuthError', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error).toBeInstanceOf(SAS9AuthError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be an instance of Error', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the prototype correctly', () => {
|
||||||
|
const error = new SAS9AuthError()
|
||||||
|
expect(Object.getPrototypeOf(error)).toBe(SAS9AuthError.prototype)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,10 +6,13 @@ export * from './Job'
|
|||||||
export * from './JobDefinition'
|
export * from './JobDefinition'
|
||||||
export * from './JobResult'
|
export * from './JobResult'
|
||||||
export * from './Link'
|
export * from './Link'
|
||||||
|
export * from './Login'
|
||||||
export * from './SASjsConfig'
|
export * from './SASjsConfig'
|
||||||
export * from './SASjsRequest'
|
export * from './RequestClient'
|
||||||
export * from './Session'
|
export * from './Session'
|
||||||
export * from './UploadFile'
|
export * from './UploadFile'
|
||||||
export * from './PollOptions'
|
export * from './PollOptions'
|
||||||
export * from './WriteStream'
|
export * from './WriteStream'
|
||||||
export * from './ExecuteScript'
|
export * from './ExecuteScript'
|
||||||
|
export * from './errors'
|
||||||
|
export * from './Tables'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SASjsRequest } from '../types/SASjsRequest'
|
import { SASjsRequest } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comparator for SASjs request timestamps.
|
* Comparator for SASjs request timestamps.
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export const SASJS_LOGS_SEPARATOR =
|
|
||||||
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
|
|
||||||
@@ -10,10 +10,14 @@ export const convertToCSV = (
|
|||||||
tableName: string
|
tableName: string
|
||||||
) => {
|
) => {
|
||||||
if (!data[tableName]) {
|
if (!data[tableName]) {
|
||||||
throw prefixMessage(
|
const error = prefixMessage(
|
||||||
'No table provided to be converted to CSV.',
|
'No table provided to be converted to CSV.',
|
||||||
'Error while converting to CSV. '
|
'Error while converting to CSV. '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (typeof error === 'string') throw new Error(error)
|
||||||
|
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = data[tableName]
|
const table = data[tableName]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isNode } from './'
|
import { isNode } from './'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
|
|
||||||
export const getFormData = () =>
|
export const getFormData = (): NodeFormData | FormData =>
|
||||||
isNode() ? new NodeFormData() : new FormData()
|
isNode() ? new NodeFormData() : new FormData()
|
||||||
|
|||||||
10
src/utils/getUserLanguage.ts
Normal file
10
src/utils/getUserLanguage.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
interface IEnavigator {
|
||||||
|
userLanguage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides preferred language of the user.
|
||||||
|
* @returns A string representing the preferred language of the user, usually the language of the browser UI. Examples of valid language codes include "en", "en-US", "fr", "fr-FR", "es-ES". More info available https://datatracker.ietf.org/doc/html/rfc5646
|
||||||
|
*/
|
||||||
|
export const getUserLanguage = () =>
|
||||||
|
window.navigator.language || (window.navigator as IEnavigator).userLanguage
|
||||||
@@ -2,7 +2,6 @@ export * from './appendExtraResponseAttributes'
|
|||||||
export * from './asyncForEach'
|
export * from './asyncForEach'
|
||||||
export * from './compareTimestamps'
|
export * from './compareTimestamps'
|
||||||
export * from './convertToCsv'
|
export * from './convertToCsv'
|
||||||
export * from './constants'
|
|
||||||
export * from './createAxiosInstance'
|
export * from './createAxiosInstance'
|
||||||
export * from './delay'
|
export * from './delay'
|
||||||
export * from './fetchLogByChunks'
|
export * from './fetchLogByChunks'
|
||||||
@@ -21,3 +20,4 @@ export * from './serialize'
|
|||||||
export * from './splitChunks'
|
export * from './splitChunks'
|
||||||
export * from './validateInput'
|
export * from './validateInput'
|
||||||
export * from './getFormData'
|
export * from './getFormData'
|
||||||
|
export * from './getUserLanguage'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getFormData } from '..'
|
import { getFormData } from '..'
|
||||||
import * as isNodeModule from '../isNode'
|
import * as isNodeModule from '../isNode'
|
||||||
import * as NodeFormData from 'form-data'
|
import NodeFormData from 'form-data'
|
||||||
|
|
||||||
describe('getFormData', () => {
|
describe('getFormData', () => {
|
||||||
it('should return NodeFormData if environment is Node', () => {
|
it('should return NodeFormData if environment is Node', () => {
|
||||||
@@ -10,8 +10,8 @@ describe('getFormData', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return FormData if environment is not Node', () => {
|
it('should return FormData if environment is not Node', () => {
|
||||||
const formDataMock = () => {}
|
// Ensure FormData is globally available
|
||||||
;(global as any).FormData = formDataMock
|
;(global as any).FormData = class FormData {}
|
||||||
|
|
||||||
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
|
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
|
||||||
|
|
||||||
|
|||||||
24
src/utils/spec/parseSasViyaLog.spec.ts
Normal file
24
src/utils/spec/parseSasViyaLog.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { parseSasViyaLog } from '../parseSasViyaLog'
|
||||||
|
|
||||||
|
describe('parseSasViyaLog', () => {
|
||||||
|
it('should parse sas viya log if environment is Node', () => {
|
||||||
|
const logResponse = {
|
||||||
|
items: [{ line: 'Line 1' }, { line: 'Line 2' }, { line: 'Line 3' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedLog = 'Line 1\nLine 2\nLine 3'
|
||||||
|
const result = parseSasViyaLog(logResponse)
|
||||||
|
expect(result).toEqual(expectedLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle exceptions and return the original logResponse', () => {
|
||||||
|
// Create a logResponse that will cause an error in the mapping process.
|
||||||
|
const logResponse: any = {
|
||||||
|
items: null
|
||||||
|
}
|
||||||
|
// Since logResponse.items is null, the ternary operator returns the else branch.
|
||||||
|
const expectedLog = JSON.stringify(logResponse)
|
||||||
|
const result = parseSasViyaLog(logResponse)
|
||||||
|
expect(result).toEqual(expectedLog)
|
||||||
|
})
|
||||||
|
})
|
||||||
72
src/utils/spec/parseViyaDebugResponse.spec.ts
Normal file
72
src/utils/spec/parseViyaDebugResponse.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
|
import { parseSasViyaDebugResponse } from '../parseViyaDebugResponse'
|
||||||
|
|
||||||
|
describe('parseSasViyaDebugResponse', () => {
|
||||||
|
let requestClient: RequestClient
|
||||||
|
const serverUrl = 'http://test-server.com'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
requestClient = {
|
||||||
|
get: jest.fn()
|
||||||
|
} as unknown as RequestClient
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract URL and call get for Viya 3.5 iframe style', async () => {
|
||||||
|
const iframeUrl = '/path/to/log.json'
|
||||||
|
const response = `<html><body><iframe style="width: 99%; height: 500px" src="${iframeUrl}"></iframe></body></html>`
|
||||||
|
const resultData = { message: 'success' }
|
||||||
|
|
||||||
|
// Mock the get method to resolve with an object containing the JSON result as string.
|
||||||
|
;(requestClient.get as jest.Mock).mockResolvedValue({
|
||||||
|
result: JSON.stringify(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await parseSasViyaDebugResponse(
|
||||||
|
response,
|
||||||
|
requestClient,
|
||||||
|
serverUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(requestClient.get).toHaveBeenCalledWith(
|
||||||
|
serverUrl + iframeUrl,
|
||||||
|
undefined,
|
||||||
|
'text/plain'
|
||||||
|
)
|
||||||
|
expect(result).toEqual(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract URL and call get for Viya 4 iframe style', async () => {
|
||||||
|
const iframeUrl = '/another/path/to/log.json'
|
||||||
|
// Note: For Viya 4, the regex splits in such a way that the extracted URL includes an extra starting double-quote.
|
||||||
|
// For example, the URL becomes: '"/another/path/to/log.json'
|
||||||
|
const response = `<html><body><iframe style="width: 99%; height: 500px; background-color:Canvas;" src="${iframeUrl}"></iframe></body></html>`
|
||||||
|
const resultData = { status: 'ok' }
|
||||||
|
|
||||||
|
;(requestClient.get as jest.Mock).mockResolvedValue({
|
||||||
|
result: JSON.stringify(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await parseSasViyaDebugResponse(
|
||||||
|
response,
|
||||||
|
requestClient,
|
||||||
|
serverUrl
|
||||||
|
)
|
||||||
|
// Expect the extra starting double-quote as per the current implementation.
|
||||||
|
const expectedUrl = serverUrl + `"` + iframeUrl
|
||||||
|
|
||||||
|
expect(requestClient.get).toHaveBeenCalledWith(
|
||||||
|
expectedUrl,
|
||||||
|
undefined,
|
||||||
|
'text/plain'
|
||||||
|
)
|
||||||
|
expect(result).toEqual(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if iframe URL is not found', async () => {
|
||||||
|
const response = `<html><body>No iframe here</body></html>`
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
parseSasViyaDebugResponse(response, requestClient, serverUrl)
|
||||||
|
).rejects.toThrow('Unable to find webout file URL.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,19 +52,22 @@ export const validateInput = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of data[key]) {
|
// ES6 is stricter so we had to include the check for the array
|
||||||
if (getType(item) !== 'object') {
|
if (Array.isArray(data[key])) {
|
||||||
return {
|
for (const item of data[key]) {
|
||||||
status: false,
|
if (getType(item) !== 'object') {
|
||||||
msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
|
return {
|
||||||
}
|
status: false,
|
||||||
} else {
|
msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
|
||||||
const attributes = Object.keys(item)
|
}
|
||||||
for (const attribute of attributes) {
|
} else {
|
||||||
if (item[attribute] === undefined) {
|
const attributes = Object.keys(item)
|
||||||
return {
|
for (const attribute of attributes) {
|
||||||
status: false,
|
if (item[attribute] === undefined) {
|
||||||
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
|
return {
|
||||||
|
status: false,
|
||||||
|
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["ES2018", "DOM", "ES2019.String"],
|
"lib": ["ES2018", "DOM", "ES2019.String"],
|
||||||
"target": "es5",
|
"target": "es6",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"outDir": "./build",
|
"outDir": "./build",
|
||||||
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
"typeRoots": ["./node_modules/@types", "./src/types/system"]
|
"typeRoots": ["./node_modules/@types", "./src/types/system"]
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ const defaultPlugins = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const optimization = {
|
const optimization = {
|
||||||
minimize: true,
|
minimize: false,
|
||||||
minimizer: [
|
minimizer: [
|
||||||
new terserPlugin({
|
// new terserPlugin({
|
||||||
parallel: true,
|
// parallel: true,
|
||||||
terserOptions: {}
|
// terserOptions: {}
|
||||||
})
|
// })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +27,15 @@ const browserConfig = {
|
|||||||
index: './src/index.ts',
|
index: './src/index.ts',
|
||||||
minified_sas9: './src/minified/sas9/index.ts'
|
minified_sas9: './src/minified/sas9/index.ts'
|
||||||
},
|
},
|
||||||
|
externals: {
|
||||||
|
'node:fs': 'node:fs',
|
||||||
|
'node:fs/promises': 'node:fs/promises',
|
||||||
|
'node:path': 'node:path',
|
||||||
|
'node:stream': 'node:stream',
|
||||||
|
'node:url': 'node:url',
|
||||||
|
'node:events': 'node:events',
|
||||||
|
'node:string_decoder': 'node:string_decoder'
|
||||||
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: '[name].js',
|
filename: '[name].js',
|
||||||
path: path.resolve(__dirname, 'build'),
|
path: path.resolve(__dirname, 'build'),
|
||||||
@@ -35,6 +44,7 @@ const browserConfig = {
|
|||||||
},
|
},
|
||||||
mode: 'production',
|
mode: 'production',
|
||||||
optimization: optimization,
|
optimization: optimization,
|
||||||
|
devtool: 'inline-source-map',
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user