@hubspot/cms-lib
Advanced tools
Comparing version
@@ -0,8 +1,15 @@ | ||
const { StatusCodeError } = require('request-promise-native/errors'); | ||
const { getPortalConfig } = require('../lib/config'); | ||
const { fetchScopeData } = require('../api/localDevAuth/authenticated'); | ||
const { | ||
ApiErrorContext, | ||
logApiErrorInstance, | ||
logFileSystemErrorInstance, | ||
logServerlessFunctionApiErrorInstance, | ||
} = require('../errorHandlers'); | ||
const { LOG_LEVEL, logger } = require('../logger'); | ||
jest.mock('../lib/config'); | ||
jest.mock('../logger'); | ||
jest.mock('../api/localDevAuth/authenticated'); | ||
@@ -182,2 +189,71 @@ function createApiError(statusCode, method) { | ||
}); | ||
describe('logServerlessApiErrorInstance', () => { | ||
const portalId = 123; | ||
const logErrorSpy = jest.spyOn(logger, 'error'); | ||
beforeEach(() => { | ||
logger.clear(); | ||
getPortalConfig.mockReturnValue({ | ||
portalId, | ||
authType: 'personalaccesskey', | ||
personalAccessKey: 'let-me-in', | ||
}); | ||
}); | ||
afterEach(() => { | ||
logErrorSpy.mockReset(); | ||
}); | ||
it('logs normal errors', async () => { | ||
const message = 'Something went wrong'; | ||
const error = new Error(message); | ||
await logServerlessFunctionApiErrorInstance( | ||
portalId, | ||
error, | ||
new ApiErrorContext({ | ||
request: 'add secret', | ||
portalId, | ||
}) | ||
); | ||
expect(logErrorSpy).toHaveBeenCalledWith( | ||
`A Error has occurred. ${message}` | ||
); | ||
}); | ||
it('detects scope error with hub scope', async () => { | ||
const error = new StatusCodeError(403, { | ||
category: 'MISSING_SCOPES', | ||
}); | ||
fetchScopeData.mockImplementation(() => | ||
Promise.resolve({ | ||
portalId, | ||
userId: 456, | ||
scopeGroup: 'cms.functions.read_write', | ||
portalScopesInGroup: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
userScopesInGroup: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
portalUserScopeIntersection: [ | ||
'SERVERLESS_FUNCTIONS', | ||
'SERVERLESS_FUNCTIONS_READ', | ||
'SERVERLESS_FUNCTIONS_WRITE', | ||
], | ||
}) | ||
); | ||
await logServerlessFunctionApiErrorInstance( | ||
portalId, | ||
error, | ||
new ApiErrorContext({ | ||
request: 'add secret', | ||
portalId, | ||
}) | ||
); | ||
expect(logErrorSpy).toHaveBeenCalledWith( | ||
'Your access key does not allow this action. Please generate a new access key by running "hs auth personalaccesskey".' | ||
); | ||
}); | ||
}); | ||
}); |
@@ -1,5 +0,9 @@ | ||
const { cleanSchema } = require('../schema'); | ||
const { cleanSchema, writeSchemaToDisk, logSchemas } = require('../schema'); | ||
const basic = require('./fixtures/schema/basic.json'); | ||
const full = require('./fixtures/schema/full.json'); | ||
const { getCwd } = require('@hubspot/cms-lib/path'); | ||
const multiple = require('./fixtures/schema/multiple.json'); | ||
const fs = require('fs-extra'); | ||
const path = require('path'); | ||
const { logger } = require('@hubspot/cms-lib/logger'); | ||
@@ -20,2 +24,21 @@ describe('cms-lib/schema', () => { | ||
}); | ||
describe('writeSchemaToDisk()', () => { | ||
it('writes schema to disk', () => { | ||
const spy = jest.spyOn(fs, 'outputFileSync'); | ||
expect(fs.existsSync(path.resolve(getCwd(), `${basic.name}.json`))).toBe( | ||
false | ||
); | ||
writeSchemaToDisk(basic); | ||
expect(spy.mock.calls[0][1]).toMatchSnapshot(); | ||
}); | ||
}); | ||
describe('logSchemas()', () => { | ||
it('logs schemas', () => { | ||
const spy = jest.spyOn(logger, 'log'); | ||
logSchemas([basic]); | ||
expect(spy.mock.calls[0][0]).toMatchSnapshot(); | ||
}); | ||
}); | ||
}); |
@@ -1,346 +0,18 @@ | ||
const { HubSpotAuthError } = require('./lib/models/Errors'); | ||
const { logger } = require('./logger'); | ||
const { | ||
ErrorContext, | ||
isFatalError, | ||
logErrorInstance, | ||
} = require('./errorHandlers/standardErrors'); | ||
const { | ||
FileSystemErrorContext, | ||
logFileSystemErrorInstance, | ||
} = require('./errorHandlers/fileSystemErrors'); | ||
const { | ||
ApiErrorContext, | ||
logApiErrorInstance, | ||
logApiUploadErrorInstance, | ||
logServerlessFunctionApiErrorInstance, | ||
parseValidationErrors, | ||
} = require('./errorHandlers/apiErrors'); | ||
const isApiStatusCodeError = err => | ||
err.name === 'StatusCodeError' || | ||
(err.statusCode >= 100 && err.statusCode < 600); | ||
const isApiUploadValidationError = err => | ||
!!( | ||
err.statusCode === 400 && | ||
err.response && | ||
err.response.body && | ||
(err.response.body.message || err.response.body.errors) | ||
); | ||
const isSystemError = err => | ||
err.errno != null && err.code != null && err.syscall != null; | ||
const isFatalError = err => err instanceof HubSpotAuthError; | ||
const isMissingScopeError = err => | ||
err.name === 'StatusCodeError' && | ||
err.statusCode === 403 && | ||
err.error.category === 'MISSING_SCOPES'; | ||
const contactSupportString = | ||
'Please try again or visit https://help.hubspot.com/ to submit a ticket or contact HubSpot Support if the issue persists.'; | ||
const parseValidationErrors = (responseBody = {}) => { | ||
const errorMessages = []; | ||
const { errors, message } = responseBody; | ||
if (message) { | ||
errorMessages.push(message); | ||
} | ||
if (errors) { | ||
const specificErrors = errors.map(error => { | ||
let errorMessage = error.message; | ||
if (error.errorTokens && error.errorTokens.line) { | ||
errorMessage = `line ${error.errorTokens.line}: ${errorMessage}`; | ||
} | ||
return errorMessage; | ||
}); | ||
errorMessages.push(...specificErrors); | ||
} | ||
return errorMessages; | ||
}; | ||
// TODO: Make these TS interfaces | ||
class ErrorContext { | ||
constructor(props = {}) { | ||
/** @type {number} */ | ||
this.portalId = props.portalId; | ||
} | ||
} | ||
class ApiErrorContext extends ErrorContext { | ||
constructor(props = {}) { | ||
super(props); | ||
/** @type {string} */ | ||
this.request = props.request || ''; | ||
/** @type {string} */ | ||
this.payload = props.payload || ''; | ||
} | ||
} | ||
class FileSystemErrorContext extends ErrorContext { | ||
constructor(props = {}) { | ||
super(props); | ||
/** @type {string} */ | ||
this.filepath = props.filepath || ''; | ||
/** @type {boolean} */ | ||
this.read = !!props.read; | ||
/** @type {boolean} */ | ||
this.write = !!props.write; | ||
} | ||
} | ||
/** | ||
* Logs (debug) the error and context objects. | ||
* | ||
* @param {SystemError} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function debugErrorAndContext(error, context) { | ||
if (error.name === 'StatusCodeError') { | ||
const { statusCode, message, response } = error; | ||
logger.debug('Error: %o', { | ||
statusCode, | ||
message, | ||
url: response.request.href, | ||
method: response.request.method, | ||
response: response.body, | ||
headers: response.headers, | ||
}); | ||
} else { | ||
logger.debug('Error: %o', error); | ||
} | ||
logger.debug('Context: %o', context); | ||
} | ||
/** | ||
* Logs a SystemError | ||
* @see {@link https://nodejs.org/api/errors.html#errors_class_systemerror} | ||
* | ||
* @param {SystemError} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function logSystemError(error, context) { | ||
logger.error(`A system error has occurred: ${error.message}`); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance of type not asserted. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ErrorContext} context | ||
*/ | ||
function logErrorInstance(error, context) { | ||
// SystemError | ||
if (isSystemError(error)) { | ||
logSystemError(error, context); | ||
return; | ||
} | ||
if (error instanceof Error || error.message || error.reason) { | ||
// Error or Error subclass | ||
const name = error.name || 'Error'; | ||
const message = [`A ${name} has occurred.`]; | ||
[error.message, error.reason].forEach(msg => { | ||
if (msg) { | ||
message.push(msg); | ||
} | ||
}); | ||
logger.error(message.join(' ')); | ||
} else { | ||
// Unknown errors | ||
logger.error(`An unknown error has occurred.`); | ||
} | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* @param {Error} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logValidationErrors(error, context) { | ||
const { response = {} } = error; | ||
const validationErrors = parseValidationErrors(response.body); | ||
if (validationErrors.length) { | ||
validationErrors.forEach(err => { | ||
logger.error(err); | ||
}); | ||
} | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Message segments for API messages. | ||
* | ||
* @enum {string} | ||
*/ | ||
const ApiMethodVerbs = { | ||
DEFAULT: 'request', | ||
DELETE: 'delete', | ||
GET: 'request', | ||
PATCH: 'update', | ||
POST: 'post', | ||
PUT: 'update', | ||
}; | ||
/** | ||
* Message segments for API messages. | ||
* | ||
* @enum {string} | ||
*/ | ||
const ApiMethodPrepositions = { | ||
DEFAULT: 'for', | ||
DELETE: 'of', | ||
GET: 'for', | ||
PATCH: 'to', | ||
POST: 'to', | ||
PUT: 'to', | ||
}; | ||
/** | ||
* Logs messages for an error instance resulting from API interaction. | ||
* | ||
* @param {StatusCodeError} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiStatusCodeError(error, context) { | ||
const { statusCode } = error; | ||
const { method } = error.options || {}; | ||
const isPutOrPost = method === 'PUT' || method === 'POST'; | ||
const action = ApiMethodVerbs[method] || ApiMethodVerbs.DEFAULT; | ||
const preposition = | ||
ApiMethodPrepositions[method] || ApiMethodPrepositions.DEFAULT; | ||
let messageDetail = ''; | ||
{ | ||
const request = context.request | ||
? `${action} ${preposition} "${context.request}"` | ||
: action; | ||
messageDetail = `${request} in portal ${context.portalId}`; | ||
} | ||
const errorMessage = []; | ||
if (isPutOrPost && context.payload) { | ||
errorMessage.push(`Unable to upload "${context.payload}".`); | ||
} | ||
switch (statusCode) { | ||
case 400: | ||
errorMessage.push(`The ${messageDetail} was bad.`); | ||
break; | ||
case 401: | ||
errorMessage.push(`The ${messageDetail} was unauthorized.`); | ||
break; | ||
case 403: | ||
errorMessage.push(`The ${messageDetail} was forbidden.`); | ||
break; | ||
case 404: | ||
if (context.request) { | ||
errorMessage.push( | ||
`The ${action} failed because "${context.request}" was not found in portal ${context.portalId}.` | ||
); | ||
} else { | ||
errorMessage.push(`The ${messageDetail} was not found.`); | ||
} | ||
break; | ||
case 503: | ||
errorMessage.push( | ||
`The ${messageDetail} could not be handled at this time. ${contactSupportString}` | ||
); | ||
break; | ||
default: | ||
if (statusCode >= 500 && statusCode < 600) { | ||
errorMessage.push( | ||
`The ${messageDetail} failed due to a server error. ${contactSupportString}` | ||
); | ||
} else if (statusCode >= 400 && statusCode < 500) { | ||
errorMessage.push(`The ${messageDetail} failed due to a client error.`); | ||
} else { | ||
errorMessage.push(`The ${messageDetail} failed.`); | ||
} | ||
break; | ||
} | ||
if (error.error && error.error.message) { | ||
errorMessage.push(error.error.message); | ||
} | ||
logger.error(errorMessage.join(' ')); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from API interaction. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiErrorInstance(error, context) { | ||
// StatusCodeError | ||
if (isApiStatusCodeError(error)) { | ||
logApiStatusCodeError(error, context); | ||
return; | ||
} | ||
logErrorInstance(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from filemapper API upload. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logApiUploadErrorInstance(error, context) { | ||
if (isApiUploadValidationError(error)) { | ||
logValidationErrors(error, context); | ||
return; | ||
} | ||
logApiErrorInstance(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from filesystem interaction. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {FileSystemErrorContext} context | ||
*/ | ||
function logFileSystemErrorInstance(error, context) { | ||
let fileAction = ''; | ||
if (context.read) { | ||
fileAction = 'reading from'; | ||
} else if (context.write) { | ||
fileAction = 'writing to'; | ||
} else { | ||
fileAction = 'accessing'; | ||
} | ||
const filepath = context.filepath | ||
? `"${context.filepath}"` | ||
: 'a file or folder'; | ||
const message = [`An error occurred while ${fileAction} ${filepath}.`]; | ||
// Many `fs` errors will be `SystemError`s | ||
if (isSystemError(error)) { | ||
message.push(`This is the result of a system error: ${error.message}`); | ||
} | ||
logger.error(message.join(' ')); | ||
debugErrorAndContext(error, context); | ||
} | ||
/** | ||
* Logs a message for an error instance resulting from API interaction | ||
* related to serverless function. | ||
* | ||
* @param {Error|SystemError|Object} error | ||
* @param {ApiErrorContext} context | ||
*/ | ||
function logServerlessFunctionApiErrorInstance(error, scopesData, context) { | ||
if (isMissingScopeError(error) && scopesData) { | ||
const { portalScopesInGroup, userScopesInGroup } = scopesData; | ||
if (!portalScopesInGroup.length) { | ||
logger.error( | ||
'Your account does not have access to this action. Talk to an account admin to request it.' | ||
); | ||
return; | ||
} | ||
if (!portalScopesInGroup.every(s => userScopesInGroup.includes(s))) { | ||
logger.error( | ||
"You don't have access to this action. Ask an account admin to change your permissions in Users & Teams settings." | ||
); | ||
return; | ||
} else { | ||
logger.error( | ||
'Your access key does not allow this action. Please generate a new access key by running "hs auth personalaccesskey".' | ||
); | ||
return; | ||
} | ||
} | ||
// StatusCodeError | ||
if (isApiStatusCodeError(error)) { | ||
logApiStatusCodeError(error, context); | ||
return; | ||
} | ||
logErrorInstance(error, context); | ||
} | ||
module.exports = { | ||
@@ -347,0 +19,0 @@ ErrorContext, |
@@ -9,5 +9,5 @@ const fs = require('fs-extra'); | ||
const { | ||
logErrorInstance, | ||
logFileSystemErrorInstance, | ||
} = require('../errorHandlers'); | ||
} = require('../errorHandlers/fileSystemErrors'); | ||
const { logErrorInstance } = require('../errorHandlers/standardErrors'); | ||
const { getCwd } = require('../path'); | ||
@@ -14,0 +14,0 @@ const { |
@@ -18,2 +18,3 @@ const request = require('request-promise-native'); | ||
tokenInfo = { expiresAt: null, refreshToken: null, accessToken: null }, | ||
name, | ||
}, | ||
@@ -32,2 +33,3 @@ logger = console, | ||
this.refreshTokenRequest = null; | ||
this.name = name; | ||
} | ||
@@ -128,2 +130,3 @@ | ||
tokenInfo: this.tokenInfo, | ||
name: this.name, | ||
}; | ||
@@ -130,0 +133,0 @@ } |
{ | ||
"name": "@hubspot/cms-lib", | ||
"version": "2.0.2-beta.2", | ||
"version": "2.1.0", | ||
"description": "Library for working with the HubSpot CMS", | ||
@@ -34,3 +34,3 @@ "license": "Apache-2.0", | ||
}, | ||
"gitHead": "d352c878526fee39e59b59d6cda43e48840fe313" | ||
"gitHead": "31128283cf58dacc24a9a1668d3e25b7eb75c353" | ||
} |
@@ -15,4 +15,4 @@ const moment = require('moment'); | ||
} = require('./lib/constants'); | ||
const { logErrorInstance } = require('./errorHandlers/standardErrors'); | ||
const { fetchAccessToken } = require('./api/localDevAuth/unauthenticated'); | ||
const { logErrorInstance } = require('./errorHandlers'); | ||
@@ -19,0 +19,0 @@ const refreshRequests = new Map(); |
@@ -50,5 +50,11 @@ const fs = require('fs-extra'); | ||
const getResolvedPath = (dest, name) => { | ||
if (name) return path.resolve(getCwd(), dest || '', `${name}.json`); | ||
return path.resolve(getCwd(), dest || ''); | ||
}; | ||
const writeSchemaToDisk = (schema, dest) => | ||
fs.outputFileSync( | ||
path.resolve(getCwd(), dest || '', `${schema.name}.json`), | ||
getResolvedPath(dest, schema.name), | ||
prettier.format(JSON.stringify(cleanSchema(schema)), { | ||
@@ -70,4 +76,5 @@ parser: 'json', | ||
response.results.forEach(r => writeSchemaToDisk(r, dest)); | ||
logger.log(`Wrote schemas to ${path.resolve(getCwd(), dest || '')}`); | ||
} | ||
return; | ||
}; | ||
@@ -82,2 +89,3 @@ | ||
writeSchemaToDisk, | ||
getResolvedPath, | ||
listSchemas, | ||
@@ -87,2 +95,3 @@ cleanSchema, | ||
downloadSchema, | ||
logSchemas, | ||
}; |
Sorry, the diff of this file is not supported yet
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
272439
1.89%84
2.44%8723
1.63%1
-50%31
3.33%