Skip to content

Commit

Permalink
Add pre-pack hook event registration validation (#81)
Browse files Browse the repository at this point in the history
* add pre-pack hook event registration validation

* add unit tests

* fix tests and refactor code

* bug fix in successful validation

* safely get param values from appConfig

* add validation for runtime actions associated with event registrations

* add unit tests

* increase test coverage

* fix bug:  handle config from ext packages

* remove unnecessary logs

* use eslint-plugin-n node14 compatible version

* revert dependency change

* update dependencies

* fix eslint-plugin-n version and use cliEnv to fetch events url

* iterate over all extensions and applications for events and runtimeManifest

* add more tests

* update aio-lib-events version

* add missing null check

* use aioLogger instead of console.log

* address review comments

* address review commit

---------

Co-authored-by: Jesse MacFadyen <purplecabbage@gmail.com>
  • Loading branch information
sangeetha5491 and purplecabbage authored Sep 15, 2023
1 parent dc811bf commit 9330a19
Show file tree
Hide file tree
Showing 8 changed files with 613 additions and 24 deletions.
16 changes: 10 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,34 @@
"@adobe/aio-lib-console": "^4.0.0",
"@adobe/aio-lib-core-config": "^4.0.0",
"@adobe/aio-lib-core-logging": "^2.0.0",
"@adobe/aio-lib-events": "^3.1.0",
"@adobe/aio-lib-core-networking": "^4.1.0",
"@adobe/aio-lib-env": "^2.0.0",
"@adobe/aio-cli-lib-app-config": "^3.0.0",
"@adobe/aio-lib-events": "^3.2.0",
"@adobe/aio-lib-ims": "^6.0.1",
"@oclif/core": "^1.5.2",
"inquirer": "^8.2.5",
"js-yaml": "^4.1.0"
},
"repository": "adobe/aio-cli-plugin-events",
"devDependencies": {
"@adobe/eslint-config-aio-lib-config": "^2.0.1",
"@adobe/eslint-config-aio-lib-config": "^2.0.2",
"@types/jest": "^29.5.3",
"acorn": "^8.10.0",
"babel-runtime": "^6.26.0",
"chalk": "^4.0.0",
"eol": "^0.9.1",
"eslint": "^8.47.0",
"eslint-config-oclif": "^3.1.0",
"eslint-config-oclif": "^4.0.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.28.0",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-jsdoc": "^42.0.0",
"eslint-plugin-n": "^16.0.1",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-standard": "^5.0.0",
"execa": "^4.0.2",
"execa": "^7.2.0",
"jest": "^29.5.0",
"jest-haste-map": "^29.5.0",
"jest-junit": "^16.0.0",
Expand All @@ -61,7 +64,8 @@
"hooks": {
"pre-deploy-event-reg": "./src/hooks/pre-deploy-event-reg.js",
"post-deploy-event-reg": "./src/hooks/post-deploy-event-reg.js",
"pre-undeploy-event-reg": "./src/hooks/pre-undeploy-event-reg.js"
"pre-undeploy-event-reg": "./src/hooks/pre-undeploy-event-reg.js",
"pre-pack": "./src/hooks/pre-pack-event-reg.js"
}
},
"scripts": {
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/pre-deploy-event-reg.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ governing permissions and limitations under the License.
const {
JOURNAL, getDeliveryType
} = require('./utils/hook-utils')
const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-events:hooks:pre-deploy-event-reg', { level: 'info' })

module.exports = async function ({ appConfig }) {
if (appConfig && appConfig.events) {
const registrations = appConfig.events.registrations
for (const registrationName in registrations) {
const deliveryType = getDeliveryType(registrations[registrationName])
if (deliveryType === JOURNAL) {
console.log('Journal registrations are not currently supported.')
aioLogger.debug('Journal registrations are not currently supported.')
return
}
}
} else {
console.log('No events to register. Skipping pre-deploy-event-reg hook')
aioLogger.debug('No events to register. Skipping pre-deploy-event-reg hook')
}
}
182 changes: 182 additions & 0 deletions src/hooks/pre-pack-event-reg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
Copyright 2023 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/
const { CLI } = require('@adobe/aio-lib-ims/src/context')
const { getToken } = require('@adobe/aio-lib-ims')
const { createFetch } = require('@adobe/aio-lib-core-networking')
const { DEFAULT_ENV, getCliEnv } = require('@adobe/aio-lib-env')
const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-events:hooks:pre-pack-event-reg', { level: 'info' })

const EVENTS_BASE_URL = {
prod: 'https://api.adobe.io/events',
stage: 'https://api-stage.adobe.io/events'
}
/**
* @returns {Promise<object>} returns headers required to make a call to the IO Events ISV validate endpoint
*/
async function getRequestHeaders () {
const X_API_KEY = process.env.SERVICE_API_KEY
if (!X_API_KEY) {
throw new Error('Required SERVICE_API_KEY is missing from .env file')
}
const accessToken = await getToken(CLI)
const headers = {}
headers.Authorization = 'Bearer ' + accessToken
headers['Content-Type'] = 'application/json'
headers['x-api-key'] = X_API_KEY
return headers
}

/**
* Handle error response
* @param {object} response The error response object obtained from making a call to the IO Events ISV validate API
* @returns {Promise<object>} returns response body of the IO Events ISV validate API call
*/
async function handleErrorResponse (response) {
if (!response.ok) {
return response.json()
.then((responseBody) => {
throw new Error(JSON.stringify(responseBody))
})
}
}

/**
* Handle request to IO Events ISV Regitration Validate API
* @param {string} validationUrl IO Events base url based on the cli environment
* @param {object} registrations The registrations from the App Builder ISV config file
* @param {object} project The project details of the ISV app
* @returns {Promise<object>} returns response object of the IO Events ISV validate API call
*/
async function handleRequest (validationUrl, registrations, project) {
const headers = await getRequestHeaders()
const url = `${validationUrl}/${project.org.id}/${project.id}/${project.workspace.id}/isv/registrations/validate`
const fetch = createFetch()
return fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(registrations)
}).then((response) => handleErrorResponse(response))
.then(() => aioLogger.info('Event registrations successfully validated'))
.catch((error) => {
throw new Error(`Error validating event registrations ${error}`)
})
}

/**
* Extracts list of event registrations to validate by IO events and list of runtime actions to validate against runtimeManifest
* @param {object} events Event registrations from the app.config.yaml file
* @returns {object} Object containing list of event registrations and list of runtime actions associated with the event registrations
*/
function extractRegistrationDetails (events) {
const registrationsList = []
const runtimeActionList = []
const registrationsFromConfig = events.registrations
for (const registrationName in registrationsFromConfig) {
const registration = {
name: registrationName,
...registrationsFromConfig[registrationName]
}
registrationsList.push(registration)
if (!registrationsFromConfig[registrationName].runtime_action) {
throw new Error(
'Invalid event registration. All Event registrations need to be associated with a runtime action')
}
runtimeActionList.push(registrationsFromConfig[registrationName].runtime_action)
}
return { registrationsList, runtimeActionList }
}

/**
* Extract Map of packages and actions associated with each package
* @param {object} manifest runtime manifest containing packages and runtime actions
* @returns {object} a map containing a mapping between the package name and list of actions in the package
*/
function extractRuntimeManifestDetails (manifest) {
const manifestPackageToRuntimeActionsMap = {}
const runtimePackages = manifest?.full?.packages || {}
for (const packageName in runtimePackages) {
if (runtimePackages[packageName].actions) {
manifestPackageToRuntimeActionsMap[packageName] = runtimePackages[packageName].actions
}
}
return manifestPackageToRuntimeActionsMap
}

/**
* Validates the runtime packages and functions associated with event registrations.
* The following validations are performed:
* a. runtime manifest contains the package name and action used in events registration
* b. the actions associated with event registrations are non-web actions
* @param {object} manifestPackageToRuntimeActionsMap a map containing a mapping between the package name and list of actions in the package
* @param {string[]} registrationRuntimeActions list of actions associated with event registrations
*/
function validateRuntimeActionsInEventRegistrations (manifestPackageToRuntimeActionsMap, registrationRuntimeActions) {
for (const registrationRuntimeAction of registrationRuntimeActions) {
const packageNameToRuntimeAction = registrationRuntimeAction.split('/')
if (packageNameToRuntimeAction.length !== 2) {
throw new Error(`Runtime action ${registrationRuntimeAction} is not correctly defined as part of a package`)
}
if (!manifestPackageToRuntimeActionsMap[packageNameToRuntimeAction[0]]) {
throw new Error(`Runtime manifest does not contain package:
${packageNameToRuntimeAction[0]} associated with ${registrationRuntimeAction}
defined in event registrations`)
}
const packageActions = manifestPackageToRuntimeActionsMap[packageNameToRuntimeAction[0]]
if (!packageActions[packageNameToRuntimeAction[1]]) {
throw new Error(`Runtime action ${registrationRuntimeAction} associated with the event registration
does not exist in the runtime manifest`)
}
const actionDetails = packageActions[packageNameToRuntimeAction[1]]
if (actionDetails.web !== 'no') {
throw new Error(`Invalid runtime action ${registrationRuntimeAction}.
Only non-web action can be registered for events`)
}
}
aioLogger.info('Validated runtime actions associated with event registrations successfully')
}

module.exports = async function ({ appConfig }) {
const applicationDetails = appConfig?.all || {}
if (!applicationDetails || Object.entries(applicationDetails).length === 0) {
aioLogger.debug('No event registrations to verify, skipping pre-pack events validation hook')
return
}
if (!appConfig?.aio?.project) {
throw new Error('No project found, error in pre-pack events validation hook')
}
let registrationsToVerify = []
let registrationRuntimeActions = []
const manifestPackageToRuntimeActionsMap = {}
Object.entries(applicationDetails).forEach(([extName, extConfig]) => {
if (extConfig.events) {
const { registrationsList, runtimeActionList } = extractRegistrationDetails(extConfig.events)
registrationsToVerify = [...registrationsToVerify, ...registrationsList]
registrationRuntimeActions = [...registrationRuntimeActions, ...runtimeActionList]
}
if (extConfig.manifest) {
const packageToRuntimeActions = extractRuntimeManifestDetails(extConfig.manifest)
for (const packageToAction in packageToRuntimeActions) {
manifestPackageToRuntimeActionsMap[packageToAction] = {
...manifestPackageToRuntimeActionsMap[packageToAction],
...packageToRuntimeActions[packageToAction]
}
}
}
})
if (registrationsToVerify?.length === 0) {
aioLogger.debug('No event registrations to verify, skipping pre-pack events validation hook')
return
}
const env = getCliEnv() || DEFAULT_ENV
const validationUrl = EVENTS_BASE_URL[env]
validateRuntimeActionsInEventRegistrations(manifestPackageToRuntimeActionsMap, registrationRuntimeActions)
await handleRequest(validationUrl, registrationsToVerify, appConfig.aio.project)
}
17 changes: 9 additions & 8 deletions src/hooks/utils/hook-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ governing permissions and limitations under the License.
const eventsSdk = require('@adobe/aio-lib-events')
const { CLI } = require('@adobe/aio-lib-ims/src/context')
const { getToken } = require('@adobe/aio-lib-ims')
const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-events:hooks', { level: 'info' })

const WEBHOOK = 'webhook'
const JOURNAL = 'journal'
Expand Down Expand Up @@ -125,11 +126,11 @@ async function createOrUpdateRegistration (body, eventsSDK, existingRegistration
if (existingRegistration) {
const response = await eventsSDK.eventsClient.updateRegistration(eventsSDK.orgId, project.id, project.workspace.id,
existingRegistration.registration_id, body)
console.log('Updated registration with name:' + response.name + ' and id:' + response.registration_id)
aioLogger.info('Updated registration with name:' + response.name + ' and id:' + response.registration_id)
} else {
const response = await eventsSDK.eventsClient.createRegistration(eventsSDK.orgId, project.id,
project.workspace.id, body)
console.log('Created registration:' + response.name + ' and id:' + response.registration_id)
aioLogger.info('Created registration:' + response.name + ' and id:' + response.registration_id)
}
}

Expand All @@ -142,7 +143,7 @@ async function createOrUpdateRegistration (body, eventsSDK, existingRegistration
async function deleteRegistration (eventsSDK, existingRegistration, project) {
await eventsSDK.eventsClient.deleteRegistration(eventsSDK.orgId, project.id, project.workspace.id,
existingRegistration.registration_id)
console.log('Deleted registration with name:' + existingRegistration.name + ' and id:' + existingRegistration.registration_id)
aioLogger.info('Deleted registration with name:' + existingRegistration.name + ' and id:' + existingRegistration.registration_id)
}

/**
Expand Down Expand Up @@ -173,8 +174,8 @@ async function deployRegistration ({ appConfig: { events, project } }, expectedD
`No project found, skipping event registration in ${hookType} hook`)
}
if (!events) {
console.log(
`No events to register, skipping event registration in ${hookType} hook`)
aioLogger.debug(
`No events to register, skipping event registration in ${hookType} hook`)
return
}
const eventsSDK = await initEventsSdk(project)
Expand Down Expand Up @@ -211,7 +212,7 @@ async function deployRegistration ({ appConfig: { events, project } }, expectedD

if (forceEventsFlag) {
const registrationsToDeleted = getWorkspaceRegistrationsToBeDeleted(Object.keys(registrationsFromWorkspace), Object.keys(registrationsFromConfig))
console.log('The following registrations will be deleted: ', registrationsToDeleted)
aioLogger.info('The following registrations will be deleted: ', registrationsToDeleted)
for (const registrationName of registrationsToDeleted) {
try {
await deleteRegistration(eventsSDK, registrationsFromWorkspace[registrationName], project)
Expand All @@ -236,7 +237,7 @@ async function undeployRegistration ({ appConfig: { events, project } }) {
'No project found, skipping deletion of event registrations')
}
if (!events) {
console.log('No events to delete, skipping deletion of event registrations')
aioLogger.debug('No events to delete, skipping deletion of event registrations')
return
}
const eventsSDK = await initEventsSdk(project)
Expand All @@ -247,7 +248,7 @@ async function undeployRegistration ({ appConfig: { events, project } }) {
const registrationsFromConfig = events.registrations
const registrationsFromWorkspace = await getAllRegistrationsForWorkspace(eventsSDK, project)
if (Object.keys(registrationsFromWorkspace).length === 0) {
console.log('No events to delete, skipping deletion of event registrations')
aioLogger.debug('No events to delete, skipping deletion of event registrations')
return
}
for (const registrationName in registrationsFromConfig) {
Expand Down
10 changes: 5 additions & 5 deletions test/hooks/post-deploy-event-reg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('post deploy event registration hook interfaces', () => {
process.env = mock.data.dotEnv
getToken.mockReturnValue('accessToken')
mockEventsSdkInstance.createRegistration.mockReturnValue(mock.data.createWebhookRegistrationResponse)
await expect(hook({ appConfig: { project: mock.data.sampleProjectWithoutEvents, events: mock.data.sampleEvents } })).resolves.not.toThrow()
await expect(hook({ appConfig: { project: mock.data.sampleProjectWithoutEvents, events: mock.data.sampleEventsWithWebhookAndJournalReg } })).resolves.not.toThrow()
expect(mockEventsSdkInstance.createRegistration).toHaveBeenCalledTimes(1)
expect(mockEventsSdkInstance.createRegistration).toHaveBeenCalledWith(CONSUMER_ID, PROJECT_ID, WORKSPACE_ID,
mock.data.hookDecodedEventRegistration1
Expand All @@ -128,7 +128,7 @@ describe('post deploy event registration hook interfaces', () => {
expect(typeof hook).toBe('function')
process.env = mock.data.dotEnv
getToken.mockReturnValue('accessToken')
const events = mock.data.sampleEvents
const events = mock.data.sampleEventsWithWebhookAndJournalReg
events.registrations['Event Registration 1'].delivery_type = 'webhook'
mockEventsSdkInstance.getAllRegistrationsForWorkspace.mockResolvedValue(mock.data.getAllWebhookRegistrationsWithEmptyResponse)
mockEventsSdkInstance.createRegistration.mockReturnValue(mock.data.createWebhookRegistrationResponse)
Expand Down Expand Up @@ -167,7 +167,7 @@ describe('post deploy event registration hook interfaces', () => {
getToken.mockReturnValue('accessToken')
mockEventsSdkInstance.getAllRegistrationsForWorkspace.mockResolvedValue(mock.data.getAllWebhookRegistrationsResponse)
mockEventsSdkInstance.updateRegistration.mockReturnValue(mock.data.createWebhookRegistrationResponse)
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEvents } })).resolves.not.toThrow()
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEventsWithWebhookAndJournalReg } })).resolves.not.toThrow()
expect(mockEventsSdkInstance.updateRegistration).toHaveBeenCalledTimes(1)
expect(mockEventsSdkInstance.updateRegistration).toHaveBeenCalledWith(CONSUMER_ID, PROJECT_ID, WORKSPACE_ID, 'REGID1',
mock.data.hookDecodedEventRegistration1
Expand Down Expand Up @@ -199,7 +199,7 @@ describe('post deploy event registration hook interfaces', () => {
getToken.mockReturnValue('accessToken')
mockEventsSdkInstance.getAllRegistrationsForWorkspace.mockResolvedValue(mock.data.getAllWebhookRegistrationsResponse)
mockEventsSdkInstance.updateRegistration.mockReturnValue(mock.data.createWebhookRegistrationResponse)
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEvents }, force: true })).resolves.not.toThrow()
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEventsWithWebhookAndJournalReg }, force: true })).resolves.not.toThrow()
expect(mockEventsSdkInstance.updateRegistration).toHaveBeenCalledTimes(1)
expect(mockEventsSdkInstance.updateRegistration).toHaveBeenCalledWith(CONSUMER_ID, PROJECT_ID, WORKSPACE_ID, 'REGID1',
mock.data.hookDecodedEventRegistration1)
Expand All @@ -214,7 +214,7 @@ describe('post deploy event registration hook interfaces', () => {
mockEventsSdkInstance.getAllRegistrationsForWorkspace.mockResolvedValue(mock.data.getAllWebhookRegistrationsWithEmptyResponse)

mockEventsSdkInstance.createRegistration.mockReturnValue(mock.data.createWebhookRegistrationResponse)
await expect(hook({ appConfig: { project: mock.data.sampleProjectWithoutEvents, events: mock.data.sampleEvents }, force: true })).resolves.not.toThrow()
await expect(hook({ appConfig: { project: mock.data.sampleProjectWithoutEvents, events: mock.data.sampleEventsWithWebhookAndJournalReg }, force: true })).resolves.not.toThrow()
expect(mockEventsSdkInstance.createRegistration).toHaveBeenCalledTimes(1)
expect(mockEventsSdkInstance.createRegistration).toHaveBeenCalledWith(CONSUMER_ID, PROJECT_ID, WORKSPACE_ID,
mock.data.hookDecodedEventRegistration1)
Expand Down
2 changes: 1 addition & 1 deletion test/hooks/pre-deploy-event-reg.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@ describe('pre deploy event registration hook interfaces', () => {
test('Registrations of journal type should return without error', async () => {
const hook = require('../../src/hooks/pre-deploy-event-reg')
expect(typeof hook).toBe('function')
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEvents } })).resolves.not.toThrow()
await expect(hook({ appConfig: { project: mock.data.sampleProject, events: mock.data.sampleEventsWithWebhookAndJournalReg } })).resolves.not.toThrow()
})
})
Loading

0 comments on commit 9330a19

Please sign in to comment.