diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg index 003f76b..cb3bd89 100755 --- a/.git-hooks/commit-msg +++ b/.git-hooks/commit-msg @@ -6,7 +6,7 @@ GREEN="\033[1;32m" # temporary file which holds the message). commit_message=$(cat "$1") -if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 \-\*]+\))?!?: .+$") then +if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 -\*]+\))?!?: .+$") then echo "${GREEN} ✔ Commit message meets Conventional Commit standards" exit 0 fi diff --git a/src/FileUploader.ts b/src/FileUploader.ts index 7001bcb..4e117df 100644 --- a/src/FileUploader.ts +++ b/src/FileUploader.ts @@ -61,15 +61,14 @@ export class FileUploader { 'Content-Type': 'text/plain' } + // currently only web approach is supported for file upload + // therefore log is part of response with debug enabled and must be parsed return this.requestClient .post(uploadUrl, formData, undefined, 'application/json', headers) .then(async (res) => { - // for web approach on Viya if ( - this.sasjsConfig.debug && - (this.sasjsConfig.useComputeApi === null || - this.sasjsConfig.useComputeApi === undefined) && - this.sasjsConfig.serverType === ServerType.SasViya + this.sasjsConfig.serverType === ServerType.SasViya && + this.sasjsConfig.debug ) { const jsonResponse = await parseSasViyaDebugResponse( res.result as string, diff --git a/src/SAS9ApiClient.ts b/src/SAS9ApiClient.ts index fb05c23..c7e171c 100644 --- a/src/SAS9ApiClient.ts +++ b/src/SAS9ApiClient.ts @@ -10,9 +10,13 @@ import { isUrl } from './utils' export class SAS9ApiClient { private requestClient: Sas9RequestClient - constructor(private serverUrl: string, private jobsPath: string) { + constructor( + private serverUrl: string, + private jobsPath: string, + allowInsecureRequests: boolean + ) { if (serverUrl) isUrl(serverUrl) - this.requestClient = new Sas9RequestClient(serverUrl, false) + this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests) } /** diff --git a/src/SASViyaApiClient.spec.ts b/src/SASViyaApiClient.spec.ts new file mode 100644 index 0000000..4063817 --- /dev/null +++ b/src/SASViyaApiClient.spec.ts @@ -0,0 +1,51 @@ +import { Logger, LogLevel } from '@sasjs/utils/logger' +import { RequestClient } from './request/RequestClient' +import { SASViyaApiClient } from './SASViyaApiClient' +import { Folder } from './types' +import { RootFolderNotFoundError } from './types/errors' + +const mockFolder: Folder = { + id: '1', + uri: '/folder', + links: [], + memberCount: 1 +} + +const requestClient = new (>RequestClient)() +const sasViyaApiClient = new SASViyaApiClient( + 'https://test.com', + '/test', + 'test context', + requestClient +) + +describe('SASViyaApiClient', () => { + beforeEach(() => { + ;(process as any).logger = new Logger(LogLevel.Off) + setupMocks() + }) + + it('should throw an error when the root folder is not found on the server', async () => { + jest + .spyOn(requestClient, 'get') + .mockImplementation(() => Promise.reject('Not Found')) + const error = await sasViyaApiClient + .createFolder('test', '/foo') + .catch((e) => e) + expect(error).toBeInstanceOf(RootFolderNotFoundError) + }) +}) + +const setupMocks = () => { + jest + .spyOn(requestClient, 'get') + .mockImplementation(() => + Promise.resolve({ result: mockFolder, etag: '', status: 200 }) + ) + + jest + .spyOn(requestClient, 'post') + .mockImplementation(() => + Promise.resolve({ result: mockFolder, etag: '', status: 200 }) + ) +} diff --git a/src/SASViyaApiClient.ts b/src/SASViyaApiClient.ts index e68082c..a8dbc9f 100644 --- a/src/SASViyaApiClient.ts +++ b/src/SASViyaApiClient.ts @@ -11,7 +11,7 @@ import { JobDefinition, PollOptions } from './types' -import { JobExecutionError } from './types/errors' +import { JobExecutionError, RootFolderNotFoundError } from './types/errors' import { SessionManager } from './SessionManager' import { ContextManager } from './ContextManager' import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types' @@ -381,7 +381,11 @@ export class SASViyaApiClient { ) const newFolderName = `${parentFolderPath.split('/').pop()}` if (newParentFolderPath === '') { - throw new Error('Root folder has to be present on the server.') + throw new RootFolderNotFoundError( + parentFolderPath, + this.serverUrl, + accessToken + ) } logger.info( `Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'` diff --git a/src/SASjs.ts b/src/SASjs.ts index 705386d..5924985 100644 --- a/src/SASjs.ts +++ b/src/SASjs.ts @@ -619,6 +619,11 @@ export default class SASjs { authConfig ) } else { + if (!config.contextName) + config = { + ...config, + contextName: 'SAS Job Execution compute context' + } return await this.jesJobExecutor!.execute( sasJob, data, @@ -749,7 +754,11 @@ export default class SASjs { ) sasApiClient.debug = this.sasjsConfig.debug } else if (this.sasjsConfig.serverType === ServerType.Sas9) { - sasApiClient = new SAS9ApiClient(serverUrl, this.jobsPath) + sasApiClient = new SAS9ApiClient( + serverUrl, + this.jobsPath, + this.sasjsConfig.allowInsecureRequests + ) } } else { let sasClientConfig: any = null @@ -944,7 +953,8 @@ export default class SASjs { else this.sas9ApiClient = new SAS9ApiClient( this.sasjsConfig.serverUrl, - this.jobsPath + this.jobsPath, + this.sasjsConfig.allowInsecureRequests ) } @@ -965,7 +975,8 @@ export default class SASjs { this.sas9JobExecutor = new Sas9JobExecutor( this.sasjsConfig.serverUrl, this.sasjsConfig.serverType!, - this.jobsPath + this.jobsPath, + this.sasjsConfig.allowInsecureRequests ) this.computeJobExecutor = new ComputeJobExecutor( diff --git a/src/SessionManager.ts b/src/SessionManager.ts index eb195a0..a0c68d7 100644 --- a/src/SessionManager.ts +++ b/src/SessionManager.ts @@ -5,10 +5,10 @@ import { prefixMessage } from '@sasjs/utils/error' import { RequestClient } from './request/RequestClient' const MAX_SESSION_COUNT = 1 -const RETRY_LIMIT: number = 3 -let RETRY_COUNT: number = 0 export class SessionManager { + private loggedErrors: NoSessionStateError[] = [] + constructor( private serverUrl: string, private contextName: string, @@ -154,69 +154,75 @@ export class SessionManager { session: Session, etag: string | null, accessToken?: string - ) { + ): Promise { const logger = process.logger || console let sessionState = session.state const stateLink = session.links.find((l: any) => l.rel === 'state') - return new Promise(async (resolve, reject) => { - if ( - sessionState === 'pending' || - sessionState === 'running' || - sessionState === '' - ) { - if (stateLink) { - if (this.debug && !this.printedSessionState.printed) { - logger.info('Polling session status...') + if ( + sessionState === 'pending' || + sessionState === 'running' || + sessionState === '' + ) { + if (stateLink) { + if (this.debug && !this.printedSessionState.printed) { + logger.info('Polling session status...') - this.printedSessionState.printed = true - } - - const { result: state, responseStatus: responseStatus } = - await this.getSessionState( - `${this.serverUrl}${stateLink.href}?wait=30`, - etag!, - accessToken - ).catch((err) => { - throw prefixMessage(err, 'Error while getting session state.') - }) - - sessionState = state.trim() - - if (this.debug && this.printedSessionState.state !== sessionState) { - logger.info(`Current session state is '${sessionState}'`) - - this.printedSessionState.state = sessionState - this.printedSessionState.printed = false - } - - // There is an internal error present in SAS Viya 3.5 - // Retry to wait for a session status in such case of SAS internal error - if (!sessionState) { - if (RETRY_COUNT < RETRY_LIMIT) { - RETRY_COUNT++ - - resolve(this.waitForSession(session, etag, accessToken)) - } else { - reject( - new NoSessionStateError( - responseStatus, - this.serverUrl + stateLink.href, - session.links.find((l: any) => l.rel === 'log') - ?.href as string - ) - ) - } - } - - resolve(sessionState) + this.printedSessionState.printed = true } + + const { result: state, responseStatus: responseStatus } = + await this.getSessionState( + `${this.serverUrl}${stateLink.href}?wait=30`, + etag!, + accessToken + ).catch((err) => { + throw prefixMessage(err, 'Error while getting session state.') + }) + + sessionState = state.trim() + + if (this.debug && this.printedSessionState.state !== sessionState) { + logger.info(`Current session state is '${sessionState}'`) + + this.printedSessionState.state = sessionState + this.printedSessionState.printed = false + } + + if (!sessionState) { + const stateError = new NoSessionStateError( + responseStatus, + this.serverUrl + stateLink.href, + session.links.find((l: any) => l.rel === 'log')?.href as string + ) + + if ( + !this.loggedErrors.find( + (err: NoSessionStateError) => + err.serverResponseStatus === stateError.serverResponseStatus + ) + ) { + this.loggedErrors.push(stateError) + + logger.info(stateError.message) + } + + return await this.waitForSession(session, etag, accessToken) + } + + this.loggedErrors = [] + + return sessionState } else { - resolve(sessionState) + throw 'Error while getting session state link.' } - }) + } else { + this.loggedErrors = [] + + return sessionState + } } private async getSessionState( diff --git a/src/job-execution/Sas9JobExecutor.ts b/src/job-execution/Sas9JobExecutor.ts index 6dbed70..3b1f77f 100644 --- a/src/job-execution/Sas9JobExecutor.ts +++ b/src/job-execution/Sas9JobExecutor.ts @@ -16,10 +16,11 @@ export class Sas9JobExecutor extends BaseJobExecutor { constructor( serverUrl: string, serverType: ServerType, - private jobsPath: string + private jobsPath: string, + allowInsecureRequests: boolean ) { super(serverUrl, serverType) - this.requestClient = new Sas9RequestClient(serverUrl, false) + this.requestClient = new Sas9RequestClient(serverUrl, allowInsecureRequests) } async execute(sasJob: string, data: any, config: any) { diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index bfe45ef..b06885c 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -63,7 +63,12 @@ export class WebJobExecutor extends BaseJobExecutor { apiUrl = apiUrl.replace('_program=', '__program=') } - apiUrl += config.contextName ? `&_contextname=${config.contextName}` : '' + // if context name exists and is not blank string + // then add _contextname variable in apiUrl + apiUrl += + config.contextName && !/\s/.test(config.contextName) + ? `&_contextname=${config.contextName}` + : '' } let requestParams = { diff --git a/src/test/SessionManager.spec.ts b/src/test/SessionManager.spec.ts index c818d4f..ace8526 100644 --- a/src/test/SessionManager.spec.ts +++ b/src/test/SessionManager.spec.ts @@ -3,6 +3,8 @@ import { RequestClient } from '../request/RequestClient' import { NoSessionStateError } from '../types/errors' import * as dotenv from 'dotenv' import axios from 'axios' +import { Logger, LogLevel } from '@sasjs/utils' +import { Session } from '../types' jest.mock('axios') const mockedAxios = axios as jest.Mocked @@ -47,36 +49,91 @@ describe('SessionManager', () => { }) describe('waitForSession', () => { + const session: Session = { + id: 'id', + state: '', + links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], + attributes: { + sessionInactiveTimeout: 0 + }, + creationTimeStamp: '' + } + + beforeEach(() => { + ;(process as any).logger = new Logger(LogLevel.Off) + }) + it('should reject with NoSessionStateError if SAS server did not provide session state', async () => { - const responseStatus = 304 + let requestAttempt = 0 + const requestAttemptLimit = 10 + const sessionState = 'idle' + + mockedAxios.get.mockImplementation(() => { + requestAttempt += 1 + + if (requestAttempt >= requestAttemptLimit) { + return Promise.resolve({ data: sessionState, status: 200 }) + } + + return Promise.resolve({ data: '', status: 304 }) + }) + + jest.spyOn((process as any).logger, 'info') + + sessionManager.debug = true + + await expect( + sessionManager['waitForSession'](session, null, 'access_token') + ).resolves.toEqual(sessionState) + + expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit) + expect((process as any).logger.info).toHaveBeenCalledTimes(3) + expect((process as any).logger.info).toHaveBeenNthCalledWith( + 1, + 'Polling session status...' + ) + expect((process as any).logger.info).toHaveBeenNthCalledWith( + 2, + `Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}` + ) + expect((process as any).logger.info).toHaveBeenNthCalledWith( + 3, + `Current session state is '${sessionState}'` + ) + }) + + it('should throw an error if there is no session link', async () => { + const customSession = JSON.parse(JSON.stringify(session)) + customSession.links = [] mockedAxios.get.mockImplementation(() => - Promise.resolve({ data: '', status: responseStatus }) + Promise.resolve({ data: customSession.state, status: 200 }) ) await expect( - sessionManager['waitForSession']( - { - id: 'id', - state: '', - links: [ - { rel: 'state', href: '', uri: '', type: '', method: 'GET' } - ], - attributes: { - sessionInactiveTimeout: 0 - }, - creationTimeStamp: '' - }, - null, - 'access_token' - ) - ).rejects.toEqual( - new NoSessionStateError( - responseStatus, - process.env.SERVER_URL as string, - 'logUrl' - ) + sessionManager['waitForSession'](customSession, null, 'access_token') + ).rejects.toContain('Error while getting session state link.') + }) + + it('should throw an error if could not get session state', async () => { + mockedAxios.get.mockImplementation(() => Promise.reject('Mocked error')) + + await expect( + sessionManager['waitForSession'](session, null, 'access_token') + ).rejects.toContain('Error while getting session state.') + }) + + it('should return session state', async () => { + const customSession = JSON.parse(JSON.stringify(session)) + customSession.state = 'completed' + + mockedAxios.get.mockImplementation(() => + Promise.resolve({ data: customSession.state, status: 200 }) ) + + await expect( + sessionManager['waitForSession'](customSession, null, 'access_token') + ).resolves.toEqual(customSession.state) }) }) }) diff --git a/src/types/errors/RootFolderNotFoundError.spec.ts b/src/types/errors/RootFolderNotFoundError.spec.ts new file mode 100644 index 0000000..a27e071 --- /dev/null +++ b/src/types/errors/RootFolderNotFoundError.spec.ts @@ -0,0 +1,40 @@ +import { RootFolderNotFoundError } from './RootFolderNotFoundError' + +describe('RootFolderNotFoundError', () => { + it('when access token is provided, error message should contain the scopes in the token', () => { + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJzY29wZS0xIiwic2NvcGUtMiJdfQ.ktqPL2ulln-8Asa2jSV9QCfDYmQuNk4tNKopxJR5xZs' + + const error = new RootFolderNotFoundError( + '/myProject', + 'https://analytium.co.uk', + token + ) + + expect(error).toBeInstanceOf(RootFolderNotFoundError) + expect(error.message).toContain('scope-1') + expect(error.message).toContain('scope-2') + }) + + it('when access token is not provided, error message should not contain scopes', () => { + const error = new RootFolderNotFoundError( + '/myProject', + 'https://analytium.co.uk' + ) + + expect(error).toBeInstanceOf(RootFolderNotFoundError) + expect(error.message).not.toContain( + 'Your access token contains the following scopes' + ) + }) + + it('should include the folder path and SASDrive URL in the message', () => { + const folderPath = '/myProject' + const serverUrl = 'https://analytium.co.uk' + const error = new RootFolderNotFoundError(folderPath, serverUrl) + + expect(error).toBeInstanceOf(RootFolderNotFoundError) + expect(error.message).toContain(folderPath) + expect(error.message).toContain(`${serverUrl}/SASDrive`) + }) +}) diff --git a/src/types/errors/RootFolderNotFoundError.ts b/src/types/errors/RootFolderNotFoundError.ts new file mode 100644 index 0000000..f5f032e --- /dev/null +++ b/src/types/errors/RootFolderNotFoundError.ts @@ -0,0 +1,24 @@ +import { decodeToken } from '@sasjs/utils/auth' + +export class RootFolderNotFoundError extends Error { + constructor( + parentFolderPath: string, + serverUrl: string, + accessToken?: string + ) { + let message: string = + `Root folder ${parentFolderPath} was not found.` + + `\nPlease check ${serverUrl}/SASDrive.` + + `\nIf the folder DOES exist then it is likely a permission problem.\n` + if (accessToken) { + const decodedToken = decodeToken(accessToken) + let scope = decodedToken.scope + scope = scope.map((element) => '* ' + element) + message += + `Your access token contains the following scopes:\n` + scope.join('\n') + } + super(message) + this.name = 'RootFolderNotFoundError' + Object.setPrototypeOf(this, RootFolderNotFoundError.prototype) + } +} diff --git a/src/types/errors/index.ts b/src/types/errors/index.ts index f8595d4..cca1a97 100644 --- a/src/types/errors/index.ts +++ b/src/types/errors/index.ts @@ -7,3 +7,4 @@ export * from './LoginRequiredError' export * from './NotFoundError' export * from './ErrorResponse' export * from './NoSessionStateError' +export * from './RootFolderNotFoundError'