mirror of
https://github.com/sasjs/adapter.git
synced 2025-12-11 09:24:35 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
8
.github/vpn/config.ovpn
vendored
8
.github/vpn/config.ovpn
vendored
@@ -3,10 +3,12 @@ client
|
||||
tls-client
|
||||
dev tun
|
||||
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
|
||||
proto tcp
|
||||
remote vpn.4gl.io 7494
|
||||
proto udp
|
||||
remote vpn.4gl.io 7194
|
||||
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
|
||||
script-security 2
|
||||
keepalive 10 120
|
||||
|
||||
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 }}
|
||||
11
.github/workflows/generateDocs.yml
vendored
11
.github/workflows/generateDocs.yml
vendored
@@ -21,7 +21,16 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
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
|
||||
run: npm ci
|
||||
|
||||
11
.github/workflows/npmpublish.yml
vendored
11
.github/workflows/npmpublish.yml
vendored
@@ -22,7 +22,16 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
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
|
||||
run: npm ci
|
||||
|
||||
@@ -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
|
||||
# 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:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -20,19 +20,22 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
|
||||
- name: Check npm audit
|
||||
run: npm audit --production --audit-level=low
|
||||
# 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
|
||||
run: npm ci
|
||||
|
||||
- name: Check code style
|
||||
run: npm run lint
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
- name: Install Rimraf
|
||||
run: npm i rimraf
|
||||
|
||||
- name: Build Package
|
||||
run: npm run package:lib
|
||||
@@ -52,6 +55,10 @@ jobs:
|
||||
USER_KEY: ${{ secrets.USER_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
|
||||
run: |
|
||||
sudo apt install apt-transport-https
|
||||
@@ -67,6 +74,9 @@ jobs:
|
||||
- name: install pm2
|
||||
run: npm i -g pm2
|
||||
|
||||
- name: Fetch SASJS server
|
||||
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
|
||||
|
||||
- name: Deploy sasjs-tests
|
||||
run: |
|
||||
npm install -g replace-in-files-cli
|
||||
@@ -75,8 +85,10 @@ jobs:
|
||||
npm i
|
||||
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='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./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
|
||||
pm2 start --name sasjs-test npm -- start
|
||||
|
||||
@@ -89,10 +101,7 @@ jobs:
|
||||
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='"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
|
||||
- name: Generate coverage report
|
||||
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cSpell.words": ["SASVIYA"]
|
||||
}
|
||||
@@ -4,93 +4,56 @@ const password = Cypress.env('password')
|
||||
const testingFinishTimeout = Cypress.env('testingFinishTimeout')
|
||||
|
||||
context('sasjs-tests', function () {
|
||||
this.beforeAll(() => {
|
||||
before(() => {
|
||||
cy.visit(sasjsTestsUrl)
|
||||
})
|
||||
|
||||
this.beforeEach(() => {
|
||||
beforeEach(() => {
|
||||
cy.reload()
|
||||
})
|
||||
|
||||
it('Should have all tests successfull', (done) => {
|
||||
function loginIfNeeded() {
|
||||
cy.get('body').then(($body) => {
|
||||
cy.wait(1000).then(() => {
|
||||
const startButton = $body.find(
|
||||
'.ui.massive.icon.primary.left.labeled.button'
|
||||
)[0]
|
||||
|
||||
if (
|
||||
!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('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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||
cy.get('input[placeholder="User Name"]')
|
||||
.should('be.visible')
|
||||
.type(username)
|
||||
cy.get('input[placeholder="Password"]')
|
||||
.should('be.visible')
|
||||
.type(password)
|
||||
cy.get('.submit-button').should('be.visible').click()
|
||||
cy.get('input[placeholder="User Name"]').should('not.exist') // Wait for login to finish
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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) => {
|
||||
cy.get('body').then(($body) => {
|
||||
cy.wait(1000).then(() => {
|
||||
const startButton = $body.find(
|
||||
'.ui.massive.icon.primary.left.labeled.button'
|
||||
)[0]
|
||||
it('Should have all tests successful with debug on', () => {
|
||||
loginIfNeeded()
|
||||
|
||||
if (
|
||||
!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').should('be.visible').click()
|
||||
|
||||
cy.get('.ui.fitted.toggle.checkbox label')
|
||||
.click()
|
||||
.then(() => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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.
@@ -43,10 +43,10 @@ module.exports = {
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 63.61,
|
||||
branches: 44.72,
|
||||
functions: 53.94,
|
||||
lines: 64.07
|
||||
statements: 64.03,
|
||||
branches: 45.11,
|
||||
functions: 54.18,
|
||||
lines: 64.53
|
||||
}
|
||||
},
|
||||
|
||||
@@ -142,6 +142,8 @@ module.exports = {
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Adds a location field to test results
|
||||
// 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",
|
||||
"preinstall": "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",
|
||||
"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}\"",
|
||||
@@ -45,42 +45,44 @@
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@cypress/webpack-preprocessor": "5.9.1",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "4.17.13",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/mime": "2.0.3",
|
||||
"@types/pem": "1.9.6",
|
||||
"@types/tough-cookie": "4.0.2",
|
||||
"copyfiles": "2.4.1",
|
||||
"cors": "^2.8.5",
|
||||
"cp": "0.2.0",
|
||||
"cypress": "7.7.0",
|
||||
"dotenv": "16.0.0",
|
||||
"express": "4.17.3",
|
||||
"jest": "27.4.7",
|
||||
"jest-extended": "2.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-extended": "4.0.2",
|
||||
"node-polyfill-webpack-plugin": "1.1.4",
|
||||
"path": "0.12.7",
|
||||
"pem": "1.14.5",
|
||||
"prettier": "2.8.7",
|
||||
"process": "0.11.10",
|
||||
"rimraf": "3.0.2",
|
||||
"semantic-release": "19.0.3",
|
||||
"terser-webpack-plugin": "5.3.6",
|
||||
"ts-jest": "27.1.3",
|
||||
"ts-jest": "29.2.6",
|
||||
"ts-loader": "9.4.0",
|
||||
"tslint": "6.1.3",
|
||||
"tslint-config-prettier": "1.18.0",
|
||||
"typedoc": "0.23.24",
|
||||
"typedoc-plugin-rename-defaults": "0.6.4",
|
||||
"typescript": "4.8.3",
|
||||
"typescript": "4.9.5",
|
||||
"webpack": "5.76.2",
|
||||
"webpack-cli": "4.9.2"
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "2.52.0",
|
||||
"axios": "0.27.2",
|
||||
"axios-cookiejar-support": "1.0.1",
|
||||
"form-data": "4.0.0",
|
||||
"@sasjs/utils": "3.5.2",
|
||||
"axios": "1.8.2",
|
||||
"axios-cookiejar-support": "5.0.5",
|
||||
"form-data": "4.0.4",
|
||||
"https": "1.0.0",
|
||||
"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": ".",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
|
||||
"@sasjs/test-framework": "1.5.7",
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/node": "^14.14.41",
|
||||
@@ -13,12 +14,12 @@
|
||||
"react": "^16.0.1",
|
||||
"react-dom": "^16.0.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-scripts": "4.0.3",
|
||||
"typescript": "^4.1.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
|
||||
"build": "NODE_OPTIONS=--openssl-legacy-provider craco build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
|
||||
@@ -42,6 +43,8 @@
|
||||
]
|
||||
},
|
||||
"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!"
|
||||
else
|
||||
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!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,26 @@
|
||||
"streamServiceName": "adapter-tests",
|
||||
"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),
|
||||
sendArrTests(adapter, appLoc),
|
||||
sendObjTests(adapter),
|
||||
specialCaseTests(adapter),
|
||||
// specialCaseTests(adapter),
|
||||
sasjsRequestTests(adapter),
|
||||
fileUploadTests(adapter)
|
||||
]
|
||||
|
||||
@@ -87,6 +87,20 @@ export const basicTests = (
|
||||
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',
|
||||
description:
|
||||
@@ -159,20 +173,6 @@ export const basicTests = (
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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)
|
||||
},
|
||||
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 (
|
||||
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
|
||||
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as https from 'https'
|
||||
import { generateTimestamp } from '@sasjs/utils/time'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { Sas9RequestClient } from './request/Sas9RequestClient'
|
||||
import { isUrl } from './utils'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isRelativePath, isUri, isUrl } from './utils'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import {
|
||||
Job,
|
||||
Session,
|
||||
@@ -28,6 +28,7 @@ import { uploadTables } from './api/viya/uploadTables'
|
||||
import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
|
||||
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
|
||||
import { refreshTokensForViya } from './auth/refreshTokensForViya'
|
||||
import { FileResource } from './types/FileResource'
|
||||
|
||||
interface JobExecutionResult {
|
||||
result?: { result: object }
|
||||
@@ -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.
|
||||
* @param folderPath - the absolute path to the folder.
|
||||
@@ -791,14 +870,14 @@ export class SASViyaApiClient {
|
||||
_webin_file_count: files.length,
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: true,
|
||||
_omitSessionResults: false,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: true
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
jobArguments['_OMITTEXTLOG'] = 'false'
|
||||
jobArguments['_OMITSESSIONRESULTS'] = 'false'
|
||||
jobArguments['_omitSessionResults'] = 'false'
|
||||
jobArguments['_DEBUG'] = 131
|
||||
}
|
||||
|
||||
@@ -941,6 +1020,7 @@ export class SASViyaApiClient {
|
||||
})
|
||||
|
||||
if (!folder) return undefined
|
||||
|
||||
return folder
|
||||
}
|
||||
|
||||
@@ -952,6 +1032,30 @@ export class SASViyaApiClient {
|
||||
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) {
|
||||
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 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(
|
||||
sourceFolder: string,
|
||||
accessToken?: string,
|
||||
limit: number = 20
|
||||
limit: number = 20,
|
||||
options?: {
|
||||
returnDetails?: boolean
|
||||
}
|
||||
) {
|
||||
// checks if 'sourceFolder' is already a URI
|
||||
const sourceFolderUri = isUri(sourceFolder)
|
||||
@@ -1018,11 +1127,20 @@ export class SASViyaApiClient {
|
||||
accessToken
|
||||
)
|
||||
|
||||
let membersToReturn = []
|
||||
|
||||
if (members && members.items) {
|
||||
return members.items.map((item: any) => item.name)
|
||||
} else {
|
||||
return []
|
||||
// If returnDetails is true, return full member details
|
||||
if (options?.returnDetails) {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
58
src/SASjs.ts
58
src/SASjs.ts
@@ -411,6 +411,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.
|
||||
* @param folderPath - path of the folder to be fetched.
|
||||
@@ -436,18 +481,23 @@ export default class SASjs {
|
||||
* 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 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(
|
||||
sourceFolder: string,
|
||||
accessToken?: string,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
returnDetails = false
|
||||
) {
|
||||
this.isMethodSupported('listFolder', [ServerType.SasViya])
|
||||
|
||||
return await this.sasViyaApiClient?.listFolder(
|
||||
sourceFolder,
|
||||
accessToken,
|
||||
limit
|
||||
limit,
|
||||
{
|
||||
returnDetails
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1170,8 +1220,8 @@ export default class SASjs {
|
||||
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||
*/
|
||||
public enableVerboseMode(
|
||||
successCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse,
|
||||
errorCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse
|
||||
successCallBack?: (response: AxiosResponse) => AxiosResponse,
|
||||
errorCallBack?: (response: AxiosError) => AxiosError
|
||||
) {
|
||||
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
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 { asyncForEach, isUrl } from './utils'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
@@ -12,6 +12,7 @@ interface ApiErrorResponse {
|
||||
|
||||
export class SessionManager {
|
||||
private loggedErrors: NoSessionStateError[] = []
|
||||
private sessionStateLinkError = 'Error while getting session state link. '
|
||||
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
@@ -28,7 +29,7 @@ export class SessionManager {
|
||||
private _debug: boolean = false
|
||||
private printedSessionState = {
|
||||
printed: false,
|
||||
state: ''
|
||||
state: SessionState.NoState
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
this.sessions.push(createdSession)
|
||||
@@ -327,32 +340,30 @@ export class SessionManager {
|
||||
etag: string | null,
|
||||
accessToken?: string
|
||||
): Promise<string> {
|
||||
let { state: sessionState } = session
|
||||
const { stateUrl } = session
|
||||
const logger = process.logger || console
|
||||
|
||||
let sessionState = session.state
|
||||
|
||||
const stateLink = session.links.find((l: any) => l.rel === 'state')
|
||||
|
||||
if (
|
||||
sessionState === 'pending' ||
|
||||
sessionState === 'running' ||
|
||||
sessionState === ''
|
||||
sessionState === SessionState.Pending ||
|
||||
sessionState === SessionState.Running ||
|
||||
sessionState === SessionState.NoState
|
||||
) {
|
||||
if (stateLink) {
|
||||
if (stateUrl) {
|
||||
if (this.debug && !this.printedSessionState.printed) {
|
||||
logger.info(`Polling: ${this.serverUrl + stateLink.href}`)
|
||||
logger.info(`Polling: ${this.serverUrl + stateUrl}`)
|
||||
|
||||
this.printedSessionState.printed = true
|
||||
}
|
||||
|
||||
const url = `${this.serverUrl}${stateLink.href}?wait=30`
|
||||
const url = `${this.serverUrl}${stateUrl}?wait=30`
|
||||
|
||||
const { result: state, responseStatus: responseStatus } =
|
||||
await this.getSessionState(url, etag!, accessToken).catch((err) => {
|
||||
throw prefixMessage(err, 'Error while waiting for session. ')
|
||||
})
|
||||
|
||||
sessionState = state.trim()
|
||||
sessionState = state.trim() as SessionState
|
||||
|
||||
if (this.debug && this.printedSessionState.state !== sessionState) {
|
||||
logger.info(`Current session state is '${sessionState}'`)
|
||||
@@ -364,7 +375,7 @@ export class SessionManager {
|
||||
if (!sessionState) {
|
||||
const stateError = new NoSessionStateError(
|
||||
responseStatus,
|
||||
this.serverUrl + stateLink.href,
|
||||
this.serverUrl + stateUrl,
|
||||
session.links.find((l: any) => l.rel === 'log')?.href as string
|
||||
)
|
||||
|
||||
@@ -386,7 +397,7 @@ export class SessionManager {
|
||||
|
||||
return sessionState
|
||||
} else {
|
||||
throw 'Error while getting session state link. '
|
||||
throw this.sessionStateLinkError
|
||||
}
|
||||
} else {
|
||||
this.loggedErrors = []
|
||||
@@ -413,7 +424,7 @@ export class SessionManager {
|
||||
return await this.requestClient
|
||||
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
|
||||
.then((res) => ({
|
||||
result: res.result as string,
|
||||
result: res.result as SessionState,
|
||||
responseStatus: res.status
|
||||
}))
|
||||
.catch((err) => {
|
||||
|
||||
@@ -170,16 +170,21 @@ export async function executeOnComputeApi(
|
||||
postedJob,
|
||||
debug,
|
||||
authConfig,
|
||||
pollOptions
|
||||
pollOptions,
|
||||
{
|
||||
session,
|
||||
sessionManager
|
||||
}
|
||||
).catch(async (err) => {
|
||||
const error = err?.response?.data
|
||||
const result = /err=[0-9]*,/.exec(error)
|
||||
|
||||
const errorCode = '5113'
|
||||
|
||||
if (result?.[0]?.slice(4, -1) === errorCode) {
|
||||
const logCount = 1000000
|
||||
const sessionLogUrl =
|
||||
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
|
||||
const logCount = 1000000
|
||||
|
||||
err.log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
@@ -187,6 +192,7 @@ export async function executeOnComputeApi(
|
||||
logCount
|
||||
)
|
||||
}
|
||||
|
||||
throw prefixMessage(err, 'Error while polling job status. ')
|
||||
})
|
||||
|
||||
@@ -205,12 +211,12 @@ export async function executeOnComputeApi(
|
||||
|
||||
let jobResult
|
||||
let log = ''
|
||||
|
||||
const logLink = currentJob.links.find((l) => l.rel === 'log')
|
||||
|
||||
if (debug && logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
|
||||
log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
@@ -223,9 +229,7 @@ export async function executeOnComputeApi(
|
||||
throw new ComputeJobExecutionError(currentJob, log)
|
||||
}
|
||||
|
||||
if (!expectWebout) {
|
||||
return { job: currentJob, log }
|
||||
}
|
||||
if (!expectWebout) return { job: currentJob, log }
|
||||
|
||||
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
|
||||
|
||||
@@ -236,6 +240,7 @@ export async function executeOnComputeApi(
|
||||
if (logLink) {
|
||||
const logUrl = `${logLink.href}/content`
|
||||
const logCount = currentJob.logStatistics?.lineCount ?? 1000000
|
||||
|
||||
log = await fetchLogByChunks(
|
||||
requestClient,
|
||||
access_token!,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Job, PollOptions, PollStrategy } from '../..'
|
||||
import { getTokens } from '../../auth/getTokens'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { JobStatePollError } from '../../types/errors'
|
||||
import { Link, WriteStream } from '../../types'
|
||||
import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
|
||||
import { delay, isNode } from '../../utils'
|
||||
|
||||
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: 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
|
||||
*/
|
||||
export async function pollJobState(
|
||||
@@ -44,7 +45,8 @@ export async function pollJobState(
|
||||
postedJob: Job,
|
||||
debug: boolean,
|
||||
authConfig?: AuthConfig,
|
||||
pollOptions?: PollOptions
|
||||
pollOptions?: PollOptions,
|
||||
jobSessionManager?: JobSessionManager
|
||||
): Promise<JobState> {
|
||||
const logger = process.logger || console
|
||||
|
||||
@@ -127,7 +129,8 @@ export async function pollJobState(
|
||||
pollOptions,
|
||||
authConfig,
|
||||
streamLog,
|
||||
logFileStream
|
||||
logFileStream,
|
||||
jobSessionManager
|
||||
)
|
||||
|
||||
currentState = result.state
|
||||
@@ -158,7 +161,8 @@ export async function pollJobState(
|
||||
defaultPollOptions,
|
||||
authConfig,
|
||||
streamLog,
|
||||
logFileStream
|
||||
logFileStream,
|
||||
jobSessionManager
|
||||
)
|
||||
|
||||
currentState = result.state
|
||||
@@ -208,7 +212,21 @@ const needsRetry = (state: string) =>
|
||||
state === JobState.Pending ||
|
||||
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,
|
||||
postedJob: Job,
|
||||
currentState: JobState,
|
||||
@@ -217,7 +235,8 @@ const doPoll = async (
|
||||
pollOptions: PollOptions,
|
||||
authConfig?: AuthConfig,
|
||||
streamLog?: boolean,
|
||||
logStream?: WriteStream
|
||||
logStream?: WriteStream,
|
||||
jobSessionManager?: JobSessionManager
|
||||
): Promise<{ state: JobState; pollCount: number }> => {
|
||||
const { maxPollCount, pollInterval } = pollOptions
|
||||
const logger = process.logger || console
|
||||
@@ -229,6 +248,40 @@ const doPoll = async (
|
||||
let startLogLine = 0
|
||||
|
||||
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(
|
||||
requestClient,
|
||||
postedJob,
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as uploadTablesModule from '../uploadTables'
|
||||
import * as getTokensModule from '../../../auth/getTokens'
|
||||
import * as formatDataModule from '../../../utils/formatDataForRequest'
|
||||
import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
|
||||
import { PollOptions } from '../../../types'
|
||||
import { PollOptions, JobSessionManager } from '../../../types'
|
||||
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
|
||||
@@ -308,6 +308,11 @@ describe('executeScript', () => {
|
||||
})
|
||||
|
||||
it('should poll for job completion when waitForResult is true', async () => {
|
||||
const jobSessionManager: JobSessionManager = {
|
||||
session: mockSession,
|
||||
sessionManager: sessionManager
|
||||
}
|
||||
|
||||
await executeOnComputeApi(
|
||||
requestClient,
|
||||
sessionManager,
|
||||
@@ -329,7 +334,8 @@ describe('executeScript', () => {
|
||||
mockJob,
|
||||
false,
|
||||
mockAuthConfig,
|
||||
defaultPollOptions
|
||||
defaultPollOptions,
|
||||
jobSessionManager
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { AuthConfig } from '@sasjs/utils/types'
|
||||
import { Job, Session } from '../../../types'
|
||||
import { Job, Session, SessionState } from '../../../types'
|
||||
|
||||
export const mockSession: Session = {
|
||||
id: 's35510n',
|
||||
state: 'idle',
|
||||
state: SessionState.Idle,
|
||||
stateUrl: '',
|
||||
links: [],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 1
|
||||
},
|
||||
creationTimeStamp: new Date().valueOf().toString()
|
||||
creationTimeStamp: new Date().valueOf().toString(),
|
||||
etag: 'etag-string'
|
||||
}
|
||||
|
||||
export const mockJob: Job = {
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
import { RequestClient } from '../../../request/RequestClient'
|
||||
import { mockAuthConfig, mockJob } from './mockResponses'
|
||||
import { pollJobState } from '../pollJobState'
|
||||
import { pollJobState, doPoll, JobState } from '../pollJobState'
|
||||
import * as getTokensModule from '../../../auth/getTokens'
|
||||
import * as saveLogModule from '../saveLog'
|
||||
import * as getFileStreamModule from '../getFileStream'
|
||||
import * as isNodeModule from '../../../utils/isNode'
|
||||
import * as delayModule from '../../../utils/delay'
|
||||
import { PollOptions, PollStrategy } from '../../../types'
|
||||
import {
|
||||
PollOptions,
|
||||
PollStrategy,
|
||||
SessionState,
|
||||
JobSessionManager
|
||||
} from '../../../types'
|
||||
import { WriteStream } from 'fs'
|
||||
import { SessionManager } from '../../../SessionManager'
|
||||
import { JobStatePollError } from '../../../types'
|
||||
|
||||
const baseUrl = 'http://localhost'
|
||||
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
|
||||
requestClient['httpClient'].defaults.baseURL = baseUrl
|
||||
|
||||
const defaultStreamLog = false
|
||||
@@ -423,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 = () => {
|
||||
jest.restoreAllMocks()
|
||||
jest.mock('../../../request/RequestClient')
|
||||
|
||||
@@ -69,5 +69,5 @@ const setupMocks = () => {
|
||||
.mockImplementation(() => Promise.resolve('Test Log'))
|
||||
jest
|
||||
.spyOn(writeStreamModule, 'writeStream')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
.mockImplementation(() => Promise.resolve(true))
|
||||
}
|
||||
|
||||
@@ -10,12 +10,22 @@ import {
|
||||
describe('writeStream', () => {
|
||||
const filename = 'test.txt'
|
||||
const content = 'test'
|
||||
|
||||
let stream: WriteStream
|
||||
|
||||
beforeAll(async () => {
|
||||
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 () => {
|
||||
await expect(writeStream(stream, content)).toResolve()
|
||||
await expect(fileExists(filename)).resolves.toEqual(true)
|
||||
@@ -25,11 +35,30 @@ describe('writeStream', () => {
|
||||
})
|
||||
|
||||
it('should reject when the write errors out', async () => {
|
||||
// Mock implementation of the write method
|
||||
jest
|
||||
.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)
|
||||
|
||||
// Assert that the error is correctly handled
|
||||
expect(error.message).toEqual('Test Error')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,14 @@ import { WriteStream } from '../../types'
|
||||
export const writeStream = async (
|
||||
stream: WriteStream,
|
||||
content: string
|
||||
): Promise<void> =>
|
||||
stream.write(content + '\n', (e: any) => {
|
||||
if (e) return Promise.reject(e)
|
||||
|
||||
return Promise.resolve()
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.write(content + '\n', (err: Error | null | undefined) => {
|
||||
if (err) {
|
||||
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 { verifySas9Login } from './verifySas9Login'
|
||||
import { verifySasViyaLogin } from './verifySasViyaLogin'
|
||||
import { isLogInSuccessHeaderPresent } from './'
|
||||
|
||||
export class AuthManager {
|
||||
public userName = ''
|
||||
@@ -14,6 +15,7 @@ export class AuthManager {
|
||||
private loginUrl: 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
|
||||
|
||||
constructor(
|
||||
private serverUrl: string,
|
||||
private serverType: ServerType,
|
||||
@@ -27,6 +29,8 @@ export class AuthManager {
|
||||
: this.serverType === ServerType.SasViya
|
||||
? '/SASLogon/logout.do?'
|
||||
: '/SASLogon/logout'
|
||||
|
||||
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +133,7 @@ export class AuthManager {
|
||||
|
||||
let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
|
||||
|
||||
let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
|
||||
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse)
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (isCredentialsVerifyError(loginResponse)) {
|
||||
@@ -214,7 +218,7 @@ export class AuthManager {
|
||||
* - a boolean `isLoggedIn`
|
||||
* - a string `userName`,
|
||||
* - a string `userFullName` and
|
||||
* - a form `loginForm` if not loggedin.
|
||||
* - a form `loginForm` if not loggedIn.
|
||||
*/
|
||||
public async checkSession(): Promise<LoginResultInternal> {
|
||||
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(
|
||||
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 './isAuthorizeFormRequired'
|
||||
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 { prefixMessage } from '@sasjs/utils/error'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { RequestClient } from '../request/RequestClient'
|
||||
import { isNode } from '../utils'
|
||||
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { AuthManager } from '../AuthManager'
|
||||
import * as dotenv from 'dotenv'
|
||||
import { ServerType } from '@sasjs/utils/types'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
mockedCurrentUserApi,
|
||||
mockLoginAuthoriseRequiredResponse,
|
||||
mockLoginSuccessResponse
|
||||
mockLoginAuthoriseRequiredResponse
|
||||
} from './mockResponses'
|
||||
import { serialize } from '../../utils'
|
||||
import * as openWebPageModule from '../openWebPage'
|
||||
import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
|
||||
import * as verifySas9LoginModule from '../verifySas9Login'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { getExpectedLogInSuccessHeader } from '../'
|
||||
|
||||
jest.mock('axios')
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||
|
||||
@@ -125,6 +130,7 @@ describe('AuthManager', () => {
|
||||
requestClient,
|
||||
authCallback
|
||||
)
|
||||
|
||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
isLoggedIn: false,
|
||||
@@ -133,8 +139,9 @@ describe('AuthManager', () => {
|
||||
loginForm: { name: 'test' }
|
||||
})
|
||||
)
|
||||
|
||||
mockedAxios.post.mockImplementation(() =>
|
||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
||||
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||
)
|
||||
|
||||
const loginResponse = await authManager.logIn(userName, password)
|
||||
@@ -152,7 +159,7 @@ describe('AuthManager', () => {
|
||||
`/SASLogon/login`,
|
||||
loginParams,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: '*/*'
|
||||
@@ -170,6 +177,7 @@ describe('AuthManager', () => {
|
||||
requestClient,
|
||||
authCallback
|
||||
)
|
||||
|
||||
jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
isLoggedIn: false,
|
||||
@@ -178,8 +186,9 @@ describe('AuthManager', () => {
|
||||
loginForm: { name: 'test' }
|
||||
})
|
||||
)
|
||||
|
||||
mockedAxios.post.mockImplementation(() =>
|
||||
Promise.resolve({ data: mockLoginSuccessResponse })
|
||||
Promise.resolve({ data: getExpectedLogInSuccessHeader() })
|
||||
)
|
||||
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
|
||||
|
||||
@@ -198,7 +207,7 @@ describe('AuthManager', () => {
|
||||
`/SASLogon/login`,
|
||||
loginParams,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: '*/*'
|
||||
@@ -247,7 +256,7 @@ describe('AuthManager', () => {
|
||||
`/SASLogon/login`,
|
||||
loginParams,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: '*/*'
|
||||
@@ -365,7 +374,7 @@ describe('AuthManager', () => {
|
||||
expect(loginResponse.userName).toEqual(userName)
|
||||
|
||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||
`/SASLogon`,
|
||||
`${serverUrl}/SASLogon`,
|
||||
'SASLogon',
|
||||
{
|
||||
width: 500,
|
||||
@@ -409,7 +418,7 @@ describe('AuthManager', () => {
|
||||
expect(loginResponse.userName).toEqual(userName)
|
||||
|
||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||
`/SASLogon`,
|
||||
`${serverUrl}/SASLogon`,
|
||||
'SASLogon',
|
||||
{
|
||||
width: 500,
|
||||
@@ -453,7 +462,7 @@ describe('AuthManager', () => {
|
||||
expect(loginResponse.userName).toEqual('')
|
||||
|
||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||
`/SASLogon`,
|
||||
`${serverUrl}/SASLogon`,
|
||||
'SASLogon',
|
||||
{
|
||||
width: 500,
|
||||
@@ -497,7 +506,7 @@ describe('AuthManager', () => {
|
||||
expect(loginResponse.userName).toEqual('')
|
||||
|
||||
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
|
||||
`/SASLogon`,
|
||||
`${serverUrl}/SASLogon`,
|
||||
'SASLogon',
|
||||
{
|
||||
width: 500,
|
||||
@@ -530,7 +539,7 @@ describe('AuthManager', () => {
|
||||
1,
|
||||
`http://test-server.com/identities/users/@currentUser`,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
responseType: 'text',
|
||||
transformResponse: undefined,
|
||||
headers: {
|
||||
@@ -564,7 +573,7 @@ describe('AuthManager', () => {
|
||||
1,
|
||||
`http://test-server.com/SASStoredProcess`,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
responseType: 'text',
|
||||
transformResponse: undefined,
|
||||
headers: {
|
||||
@@ -593,7 +602,7 @@ describe('AuthManager', () => {
|
||||
1,
|
||||
`http://test-server.com/identities/users/@currentUser`,
|
||||
{
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
responseType: 'text',
|
||||
transformResponse: undefined,
|
||||
headers: {
|
||||
@@ -612,7 +621,7 @@ describe('AuthManager', () => {
|
||||
})
|
||||
|
||||
const getHeadersJson = {
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthConfig } from '@sasjs/utils/types'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
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'
|
||||
|
||||
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
|
||||
export const mockLoginSuccessResponse = `You have signed in`
|
||||
|
||||
export const mockAuthResponse: SasAuthResponse = {
|
||||
access_token: 'acc355',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthConfig, ServerType } from '@sasjs/utils/types'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { generateToken, mockAuthResponse } from './mockResponses'
|
||||
import { RequestClient } from '../../request/RequestClient'
|
||||
import { refreshTokensForViya } from '../refreshTokensForViya'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { verifySas9Login } from '../verifySas9Login'
|
||||
import * as delayModule from '../../utils/delay'
|
||||
import { getExpectedLogInSuccessHeader } from '../'
|
||||
|
||||
describe('verifySas9Login', () => {
|
||||
const serverUrl = 'http://test-server.com'
|
||||
@@ -18,7 +19,9 @@ describe('verifySas9Login', () => {
|
||||
const popup = {
|
||||
window: {
|
||||
location: { href: serverUrl + `/SASLogon` },
|
||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
||||
document: {
|
||||
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||
}
|
||||
}
|
||||
} as unknown as Window
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { verifySasViyaLogin } from '../verifySasViyaLogin'
|
||||
import * as delayModule from '../../utils/delay'
|
||||
import { getExpectedLogInSuccessHeader } from '../'
|
||||
|
||||
describe('verifySasViyaLogin', () => {
|
||||
const serverUrl = 'http://test-server.com'
|
||||
@@ -19,7 +20,9 @@ describe('verifySasViyaLogin', () => {
|
||||
const popup = {
|
||||
window: {
|
||||
location: { href: serverUrl + `/SASLogon` },
|
||||
document: { body: { innerText: '<h3>You have signed in.</h3>' } }
|
||||
document: {
|
||||
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
|
||||
}
|
||||
}
|
||||
} as unknown as Window
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { delay } from '../utils'
|
||||
import { getExpectedLogInSuccessHeader } from './'
|
||||
|
||||
export async function verifySas9Login(loginPopup: Window): Promise<{
|
||||
isLoggedIn: boolean
|
||||
@@ -6,13 +7,17 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
|
||||
let isLoggedIn = false
|
||||
let startTime = new Date()
|
||||
let elapsedSeconds = 0
|
||||
|
||||
do {
|
||||
await delay(1000)
|
||||
if (loginPopup.closed) break
|
||||
|
||||
isLoggedIn =
|
||||
loginPopup.window.location.href.includes('SASLogon') &&
|
||||
loginPopup.window.document.body.innerText.includes('You have signed in.')
|
||||
loginPopup.window.document.body.innerText.includes(
|
||||
getExpectedLogInSuccessHeader()
|
||||
)
|
||||
|
||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { delay } from '../utils'
|
||||
import { getExpectedLogInSuccessHeader } from './'
|
||||
|
||||
export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
||||
isLoggedIn: boolean
|
||||
@@ -6,23 +7,32 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
|
||||
let isLoggedIn = false
|
||||
let startTime = new Date()
|
||||
let elapsedSeconds = 0
|
||||
|
||||
do {
|
||||
await delay(1000)
|
||||
|
||||
if (loginPopup.closed) break
|
||||
|
||||
isLoggedIn = isLoggedInSASVIYA()
|
||||
|
||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||
} while (!isLoggedIn && elapsedSeconds < 5 * 60)
|
||||
|
||||
let isAuthorized = false
|
||||
|
||||
startTime = new Date()
|
||||
|
||||
do {
|
||||
await delay(1000)
|
||||
|
||||
if (loginPopup.closed) break
|
||||
|
||||
isAuthorized =
|
||||
loginPopup.window.location.href.includes('SASLogon') ||
|
||||
loginPopup.window.document.body?.innerText?.includes(
|
||||
'You have signed in.'
|
||||
getExpectedLogInSuccessHeader()
|
||||
)
|
||||
|
||||
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
|
||||
} 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 { 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 { splitChunks } from '../utils/splitChunks'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { generateFileUploadForm } from '../generateFileUploadForm'
|
||||
import { convertToCSV } from '../../utils/convertToCsv'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import * as isNodeModule from '../../utils/isNode'
|
||||
|
||||
describe('generateFileUploadForm', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
|
||||
|
||||
if (config.debug) {
|
||||
requestParams['_omittextlog'] = 'false'
|
||||
requestParams['_omitsessionresults'] = 'false'
|
||||
requestParams['_omitSessionResults'] = 'false'
|
||||
|
||||
requestParams['_debug'] = 131
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as https from 'https'
|
||||
import { ServerType } from '@sasjs/utils/types'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import { ErrorResponse } from '../types/errors'
|
||||
import { convertToCSV, isRelativePath } from '../utils'
|
||||
import { BaseJobExecutor } from './JobExecutor'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import {
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes,
|
||||
@@ -73,8 +73,10 @@ export class SasjsJobExecutor extends BaseJobExecutor {
|
||||
/* The NodeFormData object does not set the request header - so, set it */
|
||||
const contentType =
|
||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
||||
: undefined
|
||||
? `multipart/form-data; boundary=${
|
||||
formData.getHeaders()['content-type']
|
||||
}`
|
||||
: 'multipart/form-data'
|
||||
|
||||
const requestPromise = new Promise((resolve, reject) => {
|
||||
this.requestClient!.post(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
import {
|
||||
AuthConfig,
|
||||
ExtraResponseAttributes,
|
||||
@@ -150,8 +150,10 @@ export class WebJobExecutor extends BaseJobExecutor {
|
||||
/* The NodeFormData object does not set the request header - so, set it */
|
||||
const contentType =
|
||||
formData instanceof NodeFormData && typeof FormData === 'undefined'
|
||||
? `multipart/form-data; boundary=${formData.getBoundary()}`
|
||||
: undefined
|
||||
? `multipart/form-data; boundary=${
|
||||
formData.getHeaders()['content-type']
|
||||
}`
|
||||
: 'multipart/form-data'
|
||||
|
||||
const requestPromise = new Promise((resolve, reject) => {
|
||||
this.requestClient!.post(
|
||||
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosRequestHeaders,
|
||||
AxiosResponse
|
||||
} from 'axios'
|
||||
import axios from 'axios'
|
||||
import * as https from 'https'
|
||||
import { CsrfToken } from '..'
|
||||
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
|
||||
@@ -160,7 +160,7 @@ export class RequestClient implements HttpClient {
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
headers,
|
||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||
withCredentials: true
|
||||
withXSRFToken: true
|
||||
}
|
||||
|
||||
if (contentType === 'text/plain') {
|
||||
@@ -191,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>(
|
||||
url: string,
|
||||
data: any,
|
||||
@@ -207,7 +214,7 @@ export class RequestClient implements HttpClient {
|
||||
return this.httpClient
|
||||
.post<T>(url, data, {
|
||||
headers,
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
...additionalSettings
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -234,7 +241,7 @@ export class RequestClient implements HttpClient {
|
||||
}
|
||||
|
||||
return this.httpClient
|
||||
.put<T>(url, data, { headers, withCredentials: true })
|
||||
.put<T>(url, data, { headers, withXSRFToken: true })
|
||||
.then((response) => {
|
||||
throwIfError(response)
|
||||
return this.parseResponse<T>(response)
|
||||
@@ -253,7 +260,7 @@ export class RequestClient implements HttpClient {
|
||||
const headers = this.getHeaders(accessToken, 'application/json')
|
||||
|
||||
return this.httpClient
|
||||
.delete<T>(url, { headers, withCredentials: true })
|
||||
.delete<T>(url, { headers, withXSRFToken: true })
|
||||
.then((response) => {
|
||||
throwIfError(response)
|
||||
return this.parseResponse<T>(response)
|
||||
@@ -271,7 +278,7 @@ export class RequestClient implements HttpClient {
|
||||
const headers = this.getHeaders(accessToken, 'application/json')
|
||||
|
||||
return this.httpClient
|
||||
.patch<T>(url, data, { headers, withCredentials: true })
|
||||
.patch<T>(url, data, { headers, withXSRFToken: true })
|
||||
.then((response) => {
|
||||
throwIfError(response)
|
||||
return this.parseResponse<T>(response)
|
||||
@@ -413,95 +420,17 @@ export class RequestClient implements HttpClient {
|
||||
return bodyLines.join('\n')
|
||||
}
|
||||
|
||||
private defaultInterceptionCallBack = (
|
||||
axiosResponse: AxiosResponse | AxiosError
|
||||
) => {
|
||||
// Message indicating absent value.
|
||||
const noValueMessage = 'Not provided'
|
||||
private handleAxiosResponse = (response: AxiosResponse) => {
|
||||
const { status, config, request, data } = response
|
||||
|
||||
// Fallback request object that can be safely used to form request summary.
|
||||
type FallbackRequest = { _header?: string; res: { rawHeaders: string[] } }
|
||||
// _header is not present in responses with status 1**
|
||||
// rawHeaders are not present in responses with status 1**
|
||||
let fallbackRequest: FallbackRequest = {
|
||||
_header: `${noValueMessage}\n`,
|
||||
res: { rawHeaders: [noValueMessage] }
|
||||
}
|
||||
const reqHeaders = request?._header ?? 'Not provided\n'
|
||||
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
|
||||
|
||||
// Fallback response object that can be safely used to form response summary.
|
||||
type FallbackResponse = {
|
||||
status?: number | string
|
||||
request?: FallbackRequest
|
||||
config: { data?: string }
|
||||
data?: unknown
|
||||
}
|
||||
let fallbackResponse: FallbackResponse = axiosResponse
|
||||
const resHeaders = this.formatHeaders(rawHeaders)
|
||||
const parsedResBody = this.parseInterceptedBody(data)
|
||||
|
||||
if (axios.isAxiosError(axiosResponse)) {
|
||||
const { response, request, config } = axiosResponse
|
||||
|
||||
// Try to use axiosResponse.response to form response summary.
|
||||
if (response) {
|
||||
fallbackResponse = response
|
||||
} else {
|
||||
// Try to use axiosResponse.request to form request summary.
|
||||
if (request) {
|
||||
const { _header, _currentRequest } = request
|
||||
|
||||
// Try to use axiosResponse.request._header to form request summary.
|
||||
if (_header) {
|
||||
fallbackRequest._header = _header
|
||||
}
|
||||
// Try to use axiosResponse.request._currentRequest._header to form request summary.
|
||||
else if (_currentRequest && _currentRequest._header) {
|
||||
fallbackRequest._header = _currentRequest._header
|
||||
}
|
||||
|
||||
const { res } = request
|
||||
|
||||
// Try to use axiosResponse.request.res.rawHeaders to form request summary.
|
||||
if (res && res.rawHeaders) {
|
||||
fallbackRequest.res.rawHeaders = res.rawHeaders
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback config that can be safely used to form response summary.
|
||||
const fallbackConfig = { data: noValueMessage }
|
||||
|
||||
fallbackResponse = {
|
||||
status: noValueMessage,
|
||||
request: fallbackRequest,
|
||||
config: config || fallbackConfig,
|
||||
data: noValueMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { status, config, request, data: resData } = fallbackResponse
|
||||
const { data: reqData } = config
|
||||
const { _header: reqHeaders, res } = request || fallbackRequest
|
||||
const { rawHeaders } = res
|
||||
|
||||
// Converts an array of strings into a single string with the following format:
|
||||
// <headerName>: <headerValue>
|
||||
const resHeaders = rawHeaders.reduce(
|
||||
(acc: string, value: string, i: number) => {
|
||||
if (i % 2 === 0) {
|
||||
acc += `${i === 0 ? '' : '\n'}${value}`
|
||||
} else {
|
||||
acc += `: ${value}`
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
''
|
||||
)
|
||||
|
||||
const parsedResBody = this.parseInterceptedBody(resData)
|
||||
|
||||
// HTTP response summary.
|
||||
process.logger?.info(`HTTP Request (first 50 lines):
|
||||
${reqHeaders}${this.parseInterceptedBody(reqData)}
|
||||
${reqHeaders}${this.parseInterceptedBody(config.data)}
|
||||
|
||||
HTTP Response Code: ${this.prettifyString(status)}
|
||||
|
||||
@@ -509,7 +438,70 @@ HTTP Response (first 50 lines):
|
||||
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||
`)
|
||||
|
||||
return axiosResponse
|
||||
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
|
||||
}, '')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -529,8 +521,8 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
|
||||
*/
|
||||
public enableVerboseMode = (
|
||||
successCallBack = this.defaultInterceptionCallBack,
|
||||
errorCallBack = this.defaultInterceptionCallBack
|
||||
successCallBack = this.handleAxiosResponse,
|
||||
errorCallBack = this.handleAxiosError
|
||||
) => {
|
||||
this.httpInterceptor = this.httpClient.interceptors.response.use(
|
||||
successCallBack,
|
||||
@@ -645,7 +637,7 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
||||
// Fetching root and creating CSRF cookie
|
||||
await this.httpClient
|
||||
.get('/', {
|
||||
withCredentials: true
|
||||
withXSRFToken: true
|
||||
})
|
||||
.then((response) => {
|
||||
const cookie =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as https from 'https'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import axiosCookieJarSupport from 'axios-cookiejar-support'
|
||||
import { wrapper } from 'axios-cookiejar-support'
|
||||
import * as tough from 'tough-cookie'
|
||||
import { prefixMessage } from '@sasjs/utils/error'
|
||||
import { RequestClient, throwIfError } from './RequestClient'
|
||||
@@ -17,8 +17,8 @@ export class Sas9RequestClient extends RequestClient {
|
||||
this.httpClient.defaults.validateStatus = (status) =>
|
||||
status >= 200 && status < 303
|
||||
|
||||
if (axiosCookieJarSupport) {
|
||||
axiosCookieJarSupport(this.httpClient)
|
||||
if (wrapper) {
|
||||
wrapper(this.httpClient)
|
||||
this.httpClient.defaults.jar = new tough.CookieJar()
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export class Sas9RequestClient extends RequestClient {
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
headers,
|
||||
responseType: contentType === 'text/plain' ? 'text' : 'json',
|
||||
withCredentials: true
|
||||
withXSRFToken: true
|
||||
}
|
||||
if (contentType === 'text/plain') {
|
||||
requestConfig.transformResponse = undefined
|
||||
@@ -103,7 +103,7 @@ export class Sas9RequestClient extends RequestClient {
|
||||
}
|
||||
|
||||
return this.httpClient
|
||||
.post<T>(url, data, { headers, withCredentials: true })
|
||||
.post<T>(url, data, { headers, withXSRFToken: true })
|
||||
.then(async (response) => {
|
||||
if (response.status === 302) {
|
||||
return await this.get(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
|
||||
import { SasjsParsedResponse } from '../../types'
|
||||
import { AxiosResponse } from 'axios'
|
||||
import { AxiosRequestHeaders, AxiosResponse } from 'axios'
|
||||
|
||||
describe('SasjsRequestClient', () => {
|
||||
const requestClient = new SasjsRequestClient('')
|
||||
@@ -37,7 +37,9 @@ ${SASJS_LOGS_SEPARATOR}`,
|
||||
status,
|
||||
statusText: 'ok',
|
||||
headers: { etag },
|
||||
config: {}
|
||||
config: {
|
||||
headers: {} as AxiosRequestHeaders
|
||||
}
|
||||
}
|
||||
|
||||
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||
@@ -65,7 +67,9 @@ ${printOutput}`,
|
||||
status,
|
||||
statusText: 'ok',
|
||||
headers: { etag },
|
||||
config: {}
|
||||
config: {
|
||||
headers: {} as AxiosRequestHeaders
|
||||
}
|
||||
}
|
||||
|
||||
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||
@@ -100,7 +104,9 @@ ${SASJS_LOGS_SEPARATOR}`,
|
||||
status,
|
||||
statusText: 'ok',
|
||||
headers: { etag },
|
||||
config: {}
|
||||
config: {
|
||||
headers: {} as AxiosRequestHeaders
|
||||
}
|
||||
}
|
||||
|
||||
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||
@@ -139,7 +145,9 @@ ${printOutput}`,
|
||||
status,
|
||||
statusText: 'ok',
|
||||
headers: { etag },
|
||||
config: {}
|
||||
config: {
|
||||
headers: {} as AxiosRequestHeaders
|
||||
}
|
||||
}
|
||||
|
||||
const expectedParsedResponse: SasjsParsedResponse<string> = {
|
||||
|
||||
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,7 +5,7 @@ import { app, mockedAuthResponse } from './SAS_server_app'
|
||||
import { ServerType } from '@sasjs/utils/types'
|
||||
import SASjs from '../SASjs'
|
||||
import * as axiosModules from '../utils/createAxiosInstance'
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosRequestHeaders } from 'axios'
|
||||
import {
|
||||
LoginRequiredError,
|
||||
AuthorizeError,
|
||||
@@ -24,9 +24,17 @@ const axiosActual = jest.requireActual('axios')
|
||||
jest
|
||||
.spyOn(axiosModules, 'createAxiosInstance')
|
||||
.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 SERVER_URL = `https://localhost:${PORT}/`
|
||||
|
||||
@@ -75,7 +83,7 @@ describe('RequestClient', () => {
|
||||
expect(rejectionErrorMessage).toEqual(expectedError.message)
|
||||
})
|
||||
|
||||
describe('defaultInterceptionCallBack', () => {
|
||||
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
|
||||
@@ -96,7 +104,7 @@ Connection: close
|
||||
_contextName: 'SAS Job Execution compute context',
|
||||
_OMITJSONLISTING: true,
|
||||
_OMITJSONLOG: true,
|
||||
_OMITSESSIONRESULTS: true,
|
||||
_omitSessionResults: true,
|
||||
_OMITTEXTLISTING: true,
|
||||
_OMITTEXTLOG: true
|
||||
}
|
||||
@@ -165,10 +173,6 @@ Connection: close
|
||||
})
|
||||
|
||||
it('should log parsed response with status 1**', () => {
|
||||
const spyIsAxiosError = jest
|
||||
.spyOn(axios, 'isAxiosError')
|
||||
.mockImplementation(() => true)
|
||||
|
||||
const mockedAxiosError = {
|
||||
config: {
|
||||
data: reqData
|
||||
@@ -181,7 +185,7 @@ Connection: close
|
||||
} as AxiosError
|
||||
|
||||
const requestClient = new RequestClient('')
|
||||
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
|
||||
requestClient['handleAxiosError'](mockedAxiosError)
|
||||
|
||||
const noValueMessage = 'Not provided'
|
||||
const expectedLog = `HTTP Request (first 50 lines):
|
||||
@@ -195,8 +199,6 @@ ${noValueMessage}
|
||||
`
|
||||
|
||||
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||
|
||||
spyIsAxiosError.mockReset()
|
||||
})
|
||||
|
||||
it('should log parsed response with status 2**', () => {
|
||||
@@ -209,12 +211,15 @@ ${noValueMessage}
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { data: reqData },
|
||||
config: {
|
||||
data: reqData,
|
||||
headers: {} as AxiosRequestHeaders
|
||||
},
|
||||
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||
}
|
||||
|
||||
const requestClient = new RequestClient('')
|
||||
requestClient['defaultInterceptionCallBack'](mockedResponse)
|
||||
requestClient['handleAxiosResponse'](mockedResponse)
|
||||
|
||||
const expectedLog = `HTTP Request (first 50 lines):
|
||||
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||
@@ -235,29 +240,29 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
it('should log parsed response with status 3**', () => {
|
||||
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
|
||||
|
||||
const mockedResponse: AxiosResponse = {
|
||||
data: resData,
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { data: reqData },
|
||||
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||
}
|
||||
const mockedAxiosError = {
|
||||
config: {
|
||||
data: reqData
|
||||
},
|
||||
request: {
|
||||
_currentRequest: {
|
||||
_header: reqHeaders
|
||||
}
|
||||
}
|
||||
} as AxiosError
|
||||
|
||||
const requestClient = new RequestClient('')
|
||||
requestClient['defaultInterceptionCallBack'](mockedResponse)
|
||||
requestClient['handleAxiosError'](mockedAxiosError)
|
||||
|
||||
const noValueMessage = 'Not provided'
|
||||
const expectedLog = `HTTP Request (first 50 lines):
|
||||
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||
|
||||
HTTP Response Code: ${requestClient['prettifyString'](status)}
|
||||
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
|
||||
|
||||
HTTP Response (first 50 lines):
|
||||
${resHeaders[0]}: ${resHeaders[1]}${
|
||||
requestClient['parseInterceptedBody'](resData)
|
||||
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
|
||||
: ''
|
||||
}
|
||||
${noValueMessage}
|
||||
\n${requestClient['parseInterceptedBody'](noValueMessage)}
|
||||
`
|
||||
|
||||
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
|
||||
@@ -278,7 +283,10 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { data: reqData },
|
||||
config: {
|
||||
data: reqData,
|
||||
headers: {} as AxiosRequestHeaders
|
||||
},
|
||||
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||
}
|
||||
const mockedAxiosError = {
|
||||
@@ -294,7 +302,7 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
} as AxiosError
|
||||
|
||||
const requestClient = new RequestClient('')
|
||||
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
|
||||
requestClient['handleAxiosError'](mockedAxiosError)
|
||||
|
||||
const expectedLog = `HTTP Request (first 50 lines):
|
||||
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||
@@ -328,7 +336,10 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
status,
|
||||
statusText: '',
|
||||
headers: {},
|
||||
config: { data: reqData },
|
||||
config: {
|
||||
data: reqData,
|
||||
headers: {} as AxiosRequestHeaders
|
||||
},
|
||||
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
|
||||
}
|
||||
const mockedAxiosError = {
|
||||
@@ -344,7 +355,7 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
} as AxiosError
|
||||
|
||||
const requestClient = new RequestClient('')
|
||||
requestClient['defaultInterceptionCallBack'](mockedAxiosError)
|
||||
requestClient['handleAxiosError'](mockedAxiosError)
|
||||
|
||||
const expectedLog = `HTTP Request (first 50 lines):
|
||||
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
|
||||
@@ -376,8 +387,8 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
requestClient.enableVerboseMode()
|
||||
|
||||
expect(interceptorSpy).toHaveBeenCalledWith(
|
||||
requestClient['defaultInterceptionCallBack'],
|
||||
requestClient['defaultInterceptionCallBack']
|
||||
requestClient['handleAxiosResponse'],
|
||||
requestClient['handleAxiosError']
|
||||
)
|
||||
})
|
||||
|
||||
@@ -388,12 +399,12 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
'use'
|
||||
)
|
||||
|
||||
const successCallback = (response: AxiosResponse | AxiosError) => {
|
||||
const successCallback = (response: AxiosResponse) => {
|
||||
console.log('success')
|
||||
|
||||
return response
|
||||
}
|
||||
const failureCallback = (response: AxiosResponse | AxiosError) => {
|
||||
const failureCallback = (response: AxiosError) => {
|
||||
console.log('failure')
|
||||
|
||||
return response
|
||||
@@ -429,15 +440,18 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
})
|
||||
|
||||
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('')
|
||||
let verbose: VerboseMode = 'bleached'
|
||||
requestClient.setVerboseMode(verbose)
|
||||
|
||||
jest.spyOn(UtilsModule, 'inspect')
|
||||
requestClient.setVerboseMode('bleached')
|
||||
|
||||
const testStr = JSON.stringify({ test: 'test' })
|
||||
|
||||
requestClient['prettifyString'](testStr)
|
||||
|
||||
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||
@@ -445,15 +459,11 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
||||
})
|
||||
})
|
||||
|
||||
it(`should call inspect with colors when verbose mode is set to 'true'`, () => {
|
||||
it(`should call inspect with colors when verbose mode is set to true`, () => {
|
||||
const requestClient = new RequestClient('')
|
||||
let verbose: VerboseMode = true
|
||||
requestClient.setVerboseMode(verbose)
|
||||
|
||||
jest.spyOn(UtilsModule, 'inspect')
|
||||
requestClient.setVerboseMode(true)
|
||||
|
||||
const testStr = JSON.stringify({ test: 'test' })
|
||||
|
||||
requestClient['prettifyString'](testStr)
|
||||
|
||||
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import express = require('express')
|
||||
import cors from 'cors'
|
||||
|
||||
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 = {
|
||||
access_token: 'access_token',
|
||||
token_type: 'bearer',
|
||||
@@ -12,11 +20,11 @@ export const mockedAuthResponse = {
|
||||
jti: 'jti'
|
||||
}
|
||||
|
||||
app.get('/', function (req: any, res: any) {
|
||||
app.get('/', (req: any, res: any) => {
|
||||
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
|
||||
|
||||
// capture the encoded form data
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RequestClient } from '../request/RequestClient'
|
||||
import * as dotenv from 'dotenv'
|
||||
import axios from 'axios'
|
||||
import { Logger, LogLevel } from '@sasjs/utils/logger'
|
||||
import { Session, Context } from '../types'
|
||||
import { Session, SessionState, Context } from '../types'
|
||||
|
||||
jest.mock('axios')
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>
|
||||
@@ -11,21 +11,34 @@ const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
|
||||
|
||||
describe('SessionManager', () => {
|
||||
dotenv.config()
|
||||
process.env.SERVER_URL = 'https://server.com'
|
||||
|
||||
const sessionManager = new SessionManager(
|
||||
process.env.SERVER_URL as string,
|
||||
process.env.DEFAULT_COMPUTE_CONTEXT as string,
|
||||
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('-'),
|
||||
state: '',
|
||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||
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()}`
|
||||
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
|
||||
stateUrl: sessionStateLink,
|
||||
etag: sessionEtag
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -89,19 +102,21 @@ describe('SessionManager', () => {
|
||||
describe('waitForSession', () => {
|
||||
const session: Session = {
|
||||
id: 'id',
|
||||
state: '',
|
||||
state: SessionState.NoState,
|
||||
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
|
||||
attributes: {
|
||||
sessionInactiveTimeout: 0
|
||||
},
|
||||
creationTimeStamp: ''
|
||||
creationTimeStamp: '',
|
||||
stateUrl: sessionStateLink,
|
||||
etag: sessionEtag
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
;(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
|
||||
const requestAttemptLimit = 10
|
||||
const sessionState = 'idle'
|
||||
@@ -124,15 +139,17 @@ describe('SessionManager', () => {
|
||||
sessionManager['waitForSession'](session, null, 'access_token')
|
||||
).resolves.toEqual(sessionState)
|
||||
|
||||
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
|
||||
|
||||
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
|
||||
expect((process as any).logger.info).toHaveBeenCalledTimes(3)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Polling: ${process.env.SERVER_URL}`
|
||||
`Polling: ${sessionStateUrl}`
|
||||
)
|
||||
expect((process as any).logger.info).toHaveBeenNthCalledWith(
|
||||
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(
|
||||
3,
|
||||
@@ -142,7 +159,7 @@ describe('SessionManager', () => {
|
||||
|
||||
it('should throw an error if there is no session link', async () => {
|
||||
const customSession = JSON.parse(JSON.stringify(session))
|
||||
customSession.links = []
|
||||
customSession.stateUrl = ''
|
||||
|
||||
mockedAxios.get.mockImplementation(() =>
|
||||
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 () => {
|
||||
const gettingSessionStatus = 500
|
||||
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(() =>
|
||||
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(
|
||||
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 {}
|
||||
@@ -1,15 +1,34 @@
|
||||
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 {
|
||||
id: string
|
||||
state: string
|
||||
state: SessionState
|
||||
stateUrl: string
|
||||
links: Link[]
|
||||
attributes: {
|
||||
sessionInactiveTimeout: number
|
||||
}
|
||||
creationTimeStamp: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface SessionVariable {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface JobSessionManager {
|
||||
session: Session
|
||||
sessionManager: SessionManager
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export interface WriteStream {
|
||||
write: (content: string, callback: (err?: Error) => any) => void
|
||||
path: string
|
||||
import { WriteStream as FsWriteStream } from 'fs'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -10,10 +10,14 @@ export const convertToCSV = (
|
||||
tableName: string
|
||||
) => {
|
||||
if (!data[tableName]) {
|
||||
throw prefixMessage(
|
||||
const error = prefixMessage(
|
||||
'No table provided to be converted to CSV.',
|
||||
'Error while converting to CSV. '
|
||||
)
|
||||
|
||||
if (typeof error === 'string') throw new Error(error)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const table = data[tableName]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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()
|
||||
|
||||
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
|
||||
@@ -20,3 +20,4 @@ export * from './serialize'
|
||||
export * from './splitChunks'
|
||||
export * from './validateInput'
|
||||
export * from './getFormData'
|
||||
export * from './getUserLanguage'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getFormData } from '..'
|
||||
import * as isNodeModule from '../isNode'
|
||||
import * as NodeFormData from 'form-data'
|
||||
import NodeFormData from 'form-data'
|
||||
|
||||
describe('getFormData', () => {
|
||||
it('should return NodeFormData if environment is Node', () => {
|
||||
@@ -10,8 +10,8 @@ describe('getFormData', () => {
|
||||
})
|
||||
|
||||
it('should return FormData if environment is not Node', () => {
|
||||
const formDataMock = () => {}
|
||||
;(global as any).FormData = formDataMock
|
||||
// Ensure FormData is globally available
|
||||
;(global as any).FormData = class FormData {}
|
||||
|
||||
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]) {
|
||||
if (getType(item) !== 'object') {
|
||||
return {
|
||||
status: false,
|
||||
msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
|
||||
}
|
||||
} else {
|
||||
const attributes = Object.keys(item)
|
||||
for (const attribute of attributes) {
|
||||
if (item[attribute] === undefined) {
|
||||
return {
|
||||
status: false,
|
||||
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
|
||||
// ES6 is stricter so we had to include the check for the array
|
||||
if (Array.isArray(data[key])) {
|
||||
for (const item of data[key]) {
|
||||
if (getType(item) !== 'object') {
|
||||
return {
|
||||
status: false,
|
||||
msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
|
||||
}
|
||||
} else {
|
||||
const attributes = Object.keys(item)
|
||||
for (const attribute of attributes) {
|
||||
if (item[attribute] === undefined) {
|
||||
return {
|
||||
status: false,
|
||||
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2018", "DOM", "ES2019.String"],
|
||||
"target": "es5",
|
||||
"target": "es6",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "./build",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"typeRoots": ["./node_modules/@types", "./src/types/system"]
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -13,12 +13,12 @@ const defaultPlugins = [
|
||||
]
|
||||
|
||||
const optimization = {
|
||||
minimize: true,
|
||||
minimize: false,
|
||||
minimizer: [
|
||||
new terserPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {}
|
||||
})
|
||||
// new terserPlugin({
|
||||
// parallel: true,
|
||||
// terserOptions: {}
|
||||
// })
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,15 @@ const browserConfig = {
|
||||
index: './src/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: {
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'build'),
|
||||
@@ -35,6 +44,7 @@ const browserConfig = {
|
||||
},
|
||||
mode: 'production',
|
||||
optimization: optimization,
|
||||
devtool: 'inline-source-map',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user