Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ const getSpinner = () => {
const PROJECT_TYPE = 'jaeger'
const CERT_VALID_DAYS = 365

// Org filtering. Enterprise orgs (`entp`) can always be used by App Builder
// flows. Developer-type orgs (e.g. App Builder trial orgs) are only usable
// when the `RUNTIME` feature flag is enabled on the org.
const ORG_TYPE_ENTERPRISE = 'entp'
const ORG_TYPE_DEVELOPER = 'developer'
const ORG_FEATURE_RUNTIME = 'RUNTIME'

/**
* @typedef {object} ConsoleCredentials
* @property {string} accessToken access token entitled to access the Console Transporter API
Expand Down Expand Up @@ -77,9 +84,76 @@ class LibConsoleCLI {
async getOrganizations () {
getSpinner().start('Getting Organizations...')
const organizations = (await this.sdkClient.getOrganizations()).body
getSpinner().stop()
logger.debug(`Get Organizations response: ${JSON.stringify(organizations, null, 2)}`)
return organizations
const selectable = await this.filterToSelectableOrgs(organizations)
getSpinner().stop()
logger.debug(`Selectable Organizations: ${JSON.stringify(selectable, null, 2)}`)
return selectable
}

/**
* Filter an org list to only those usable by App Builder / Console
* flows (entp and developer orgs with runtime)
*
* If the feature lookup fails for a dev org we conservatively keep
* the org in the list so a transient outage doesn't block trial users
*
* @private
* @param {Array<object>} organizations raw org list from the Console API
* @returns {Promise<Array<object>>} selectable orgs preserving input order
*/
async filterToSelectableOrgs (organizations) {
if (!Array.isArray(organizations) || organizations.length === 0) {
return organizations || []
}
const checks = await Promise.all(organizations.map(async org => {
if (org.type === ORG_TYPE_ENTERPRISE) {
return true
}
if (org.type === ORG_TYPE_DEVELOPER) {
return this.hasOrgFeature(org.id, ORG_FEATURE_RUNTIME, { defaultOnError: true })
}
return false
}))
return organizations.filter((_, i) => checks[i])
}

/**
* Fetch the feature flags enabled on an org.
*
* Lets SDK errors bubble (matching the rest of the lib) so callers can
* decide their own retry / fail-open strategy. Returns `[]` only when the
* SDK returns a successful response with no body.
*
* @param {string} orgId Organization AMS ID
* @returns {Promise<Array<{name: string}>>} the feature list
*/
async getOrganizationFeatures (orgId) {
return (await this.sdkClient.getOrganizationFeatures(orgId)).body || []
}

/**
* Check whether an org has a given feature flag enabled.
*
* Delegates to `getOrganizationFeatures` so the SDK call and body
* normalisation stay in one place. The catch here only handles the
* feature-lookup itself failing
*
* @param {string} orgId Organization AMS ID
* @param {string} featureName Feature flag name (e.g. `RUNTIME`)
* @param {object} [options] options bag
* @param {boolean} [options.defaultOnError] value to return when the
* feature lookup itself fails (defaults to `false`)
* @returns {Promise<boolean>} true when the feature is enabled
*/
async hasOrgFeature (orgId, featureName, options = {}) {
try {
const features = await this.getOrganizationFeatures(orgId)
return features.some(f => f && f.name === featureName)
Comment thread
MichaelGoberling marked this conversation as resolved.
} catch (err) {
logger.debug(`hasOrgFeature(${orgId}, ${featureName}) failed: ${err}`)
Comment thread
MichaelGoberling marked this conversation as resolved.
return options.defaultOnError === true
}
}

async promptForSelectOrganization (organizations, data = { orgId: undefined, orgCode: undefined }) {
Expand Down
8 changes: 4 additions & 4 deletions lib/pure-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ const logger = require('@adobe/aio-lib-core-logging')(

/** @private */
function orgsToPromptChoices (orgs) {
return orgs
// we only support entp orgs for now
.filter(item => item.type === 'entp')
.map(item => ({ name: item.name, value: item }))
// Type-based filtering is intentionally not done here: the list is already
// filtered by `LibConsoleCLI.getOrganizations` to the orgs that can be used
// (entp + developer orgs with the Runtime feature)
return orgs.map(item => ({ name: item.name, value: item }))
}

/** @private */
Expand Down
32 changes: 27 additions & 5 deletions test/data-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,29 @@ const organizations = [
type: 'entp',
roles: [], // no need to mock roles for now
role: 'DEVELOPER'
},
{
id: '67891',
code: '33333333333MMMMMMMMMDDDD@AdobeOrg',
name: 'Trial App Builder org',
description: 'developer-type org with the Runtime feature',
type: 'developer',
roles: [],
role: 'DEVELOPER'
}
]
const org = organizations[0]
// mocked feature-flag map keyed by org id; only orgs of type 'developer'
// that need to appear as selectable should have RUNTIME.
const orgFeaturesById = {
67891: [{ name: 'RUNTIME', description: 'OpenWhisk runtime' }]
}
// orgs the lib should expose to callers: entp ∪ developer-with-RUNTIME
const selectableOrganizations = [
organizations[0],
organizations[2],
organizations[3]
]

const projects = [{
name: 'myFirstProject',
Expand Down Expand Up @@ -501,12 +521,12 @@ const subscribeServicesResponseOAuthServerToServer = {
sdkList: integrationOAuthServerToServer.sdkList
}

// expected prompt choices, based on data above and filters
// expected prompt choices, based on data above and filters.
// `orgsToPromptChoices` no longer filters by org type — that filtering now
// happens once in `LibConsoleCLI.getOrganizations` — so the prompt choices
// reflect every org in the input list.
const promptChoices = {
orgs: [
{ name: organizations[0].name, value: organizations[0] },
{ name: organizations[2].name, value: organizations[2] }
],
orgs: organizations.map(o => ({ name: o.name, value: o })),
projects: [
{ name: projects[4].title, value: projects[4] },
{ name: projects[1].title, value: projects[1] }
Expand Down Expand Up @@ -647,6 +667,8 @@ const applicationExtensions = {

module.exports = {
organizations,
selectableOrganizations,
orgFeaturesById,
projects,
workspaces,
org,
Expand Down
83 changes: 81 additions & 2 deletions test/lib/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jest.mock('@adobe/aio-lib-console')
const consoleSDK = require('@adobe/aio-lib-console')
const mockConsoleSDKInstance = {
getOrganizations: jest.fn(),
getOrganizationFeatures: jest.fn(),
getProjectsForOrg: jest.fn(),
getWorkspacesForProject: jest.fn(),
getServicesForOrg: jest.fn(),
Expand Down Expand Up @@ -56,6 +57,9 @@ function resetMockConsoleSDK () {
/** @private */
function setDefaultMockConsoleSdk () {
mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: dataMocks.organizations })
mockConsoleSDKInstance.getOrganizationFeatures.mockImplementation(orgId => Promise.resolve({
body: dataMocks.orgFeaturesById[orgId] || []
}))
mockConsoleSDKInstance.getProjectsForOrg.mockResolvedValue({ body: dataMocks.projects })
mockConsoleSDKInstance.getWorkspacesForProject.mockResolvedValue({ body: dataMocks.workspaces })
mockConsoleSDKInstance.getServicesForOrg.mockResolvedValue({ body: dataMocks.services })
Expand Down Expand Up @@ -189,6 +193,9 @@ test('instance methods definitions', async () => {
expect(typeof consoleCli.getFirstEntpCredentials).toBe('function')
expect(typeof consoleCli.getFirstOAuthServerToServerCredentials).toBe('function')
expect(typeof consoleCli.getOrganizations).toBe('function')
expect(typeof consoleCli.getOrganizationFeatures).toBe('function')
expect(typeof consoleCli.hasOrgFeature).toBe('function')
expect(typeof consoleCli.filterToSelectableOrgs).toBe('function')
expect(typeof consoleCli.getProjects).toBe('function')
expect(typeof consoleCli.getProject).toBe('function')
expect(typeof consoleCli.getApplicationExtensions).toBe('function')
Expand Down Expand Up @@ -227,14 +234,86 @@ describe('instance methods tests', () => {
consoleCli = await LibConsoleCli.init(consoleCredentials)
})

test('getOrganizations', async () => {
test('getOrganizations returns entp + developer-with-RUNTIME orgs', async () => {
const organizations = await consoleCli.getOrganizations()
expect(organizations).toEqual(dataMocks.organizations)
expect(organizations).toEqual(dataMocks.selectableOrganizations)
expect(mockConsoleSDKInstance.getOrganizations).toHaveBeenCalled()
// features endpoint should only be hit for non-entp orgs
expect(mockConsoleSDKInstance.getOrganizationFeatures).toHaveBeenCalledWith('55555')
expect(mockConsoleSDKInstance.getOrganizationFeatures).toHaveBeenCalledWith('67891')
expect(mockConsoleSDKInstance.getOrganizationFeatures).not.toHaveBeenCalledWith('12345')
expect(mockConsoleSDKInstance.getOrganizationFeatures).not.toHaveBeenCalledWith('67890')
expect(mockOraObject.start).toHaveBeenCalled()
expect(mockOraObject.stop).toHaveBeenCalled()
})

test('getOrganizations keeps developer orgs when the feature lookup throws (fail-open)', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockImplementation(orgId => {
if (orgId === '55555') {
return Promise.reject(new Error('features endpoint down'))
}
return Promise.resolve({ body: dataMocks.orgFeaturesById[orgId] || [] })
})
const organizations = await consoleCli.getOrganizations()
// 55555 is conservatively included because we couldn't determine feature state
expect(organizations.map(o => o.id)).toEqual(['12345', '55555', '67890', '67891'])
})

test('getOrganizations excludes developer orgs without RUNTIME', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: [] })
const organizations = await consoleCli.getOrganizations()
expect(organizations.map(o => o.id)).toEqual(['12345', '67890'])
})

test('getOrganizations excludes orgs whose type is neither entp nor developer', async () => {
mockConsoleSDKInstance.getOrganizations.mockResolvedValue({
body: [
{ id: '12345', type: 'entp' },
{ id: 'something-else', type: 'other-org-type' }
]
})
const organizations = await consoleCli.getOrganizations()
expect(organizations.map(o => o.id)).toEqual(['12345'])
})

test('getOrganizations passes through empty / falsy raw responses', async () => {
mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: [] })
await expect(consoleCli.getOrganizations()).resolves.toEqual([])
mockConsoleSDKInstance.getOrganizations.mockResolvedValue({ body: null })
await expect(consoleCli.getOrganizations()).resolves.toEqual([])
})

test('getOrganizationFeatures returns the body on success', async () => {
await expect(consoleCli.getOrganizationFeatures('67891'))
.resolves.toEqual(dataMocks.orgFeaturesById['67891'])
})

test('getOrganizationFeatures lets SDK errors bubble', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockRejectedValue(new Error('boom'))
await expect(consoleCli.getOrganizationFeatures('67891')).rejects.toThrow('boom')
})

test('getOrganizationFeatures returns [] when the SDK response has no body', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: null })
await expect(consoleCli.getOrganizationFeatures('67891')).resolves.toEqual([])
})

test('hasOrgFeature reflects RUNTIME presence', async () => {
await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(true)
await expect(consoleCli.hasOrgFeature('55555', 'RUNTIME')).resolves.toBe(false)
})

test('hasOrgFeature returns false when the SDK response has no body', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockResolvedValue({ body: null })
await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(false)
})

test('hasOrgFeature returns defaultOnError when the SDK throws', async () => {
mockConsoleSDKInstance.getOrganizationFeatures.mockRejectedValue(new Error('boom'))
await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME')).resolves.toBe(false)
await expect(consoleCli.hasOrgFeature('67891', 'RUNTIME', { defaultOnError: true })).resolves.toBe(true)
})

test('getProjects', async () => {
const projects = await consoleCli.getProjects('orgid')
expect(projects).toEqual(dataMocks.projects)
Expand Down
2 changes: 1 addition & 1 deletion test/lib/pure-helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test('exports', () => {
})

describe('orgsToPromptChoices', () => {
test('with input that has entp and non entp orgs', () => {
test('returns prompt choices for every input org (no type filtering)', () => {
expect(helpers.orgsToPromptChoices(dataMocks.organizations))
.toEqual(dataMocks.promptChoices.orgs)
})
Expand Down
Loading