diff --git a/src/commands/console/index.js b/src/commands/console/index.js index 6904439..cbed2fd 100644 --- a/src/commands/console/index.js +++ b/src/commands/console/index.js @@ -18,7 +18,7 @@ const LibConsoleCLI = require('@adobe/aio-cli-lib-console') const { CLI } = require('@adobe/aio-lib-ims/src/context') const { getCliEnv } = require('@adobe/aio-lib-env') const yaml = require('js-yaml') -const { CONFIG_KEYS, API_KEYS } = require('../../config') +const { CONFIG_KEYS, API_KEYS, CONSOLE_API_URLS, ORG_FEATURE_RUNTIME, ORG_TYPE_DEVELOPER, ORG_TYPE_ENTERPRISE } = require('../../config') class ConsoleCommand extends Command { async run () { @@ -36,6 +36,63 @@ class ConsoleCommand extends Command { this.consoleCLI = await LibConsoleCLI.init({ accessToken: this.accessToken, apiKey: this.apiKey, env: this.cliEnv }) } + /** + * Retrieve enabled feature flags for an org from the Developer Console web API. + * + * @param {string} orgId Organization AMS ID + * @returns {Promise>} feature flags + */ + async getOrgFeatures (orgId) { + const baseUrl = CONSOLE_API_URLS[this.cliEnv] || CONSOLE_API_URLS.prod + const response = await fetch(`${baseUrl}/console/api/organizations/${orgId}/features`, { + headers: { + accept: 'application/json', + authorization: `Bearer ${this.accessToken}`, + 'x-api-key': this.apiKey + } + }) + if (!response.ok) { + aioConsoleLogger.debug(`getOrgFeatures: non-ok response ${response.status} for org ${orgId}`) + return [] + } + return response.json() + } + + /** + * Test whether an org has the Runtime feature. + * + * @param {string} orgId Organization AMS ID + * @returns {Promise} true when Runtime is enabled + */ + async hasRuntimeFeature (orgId) { + try { + const features = await this.getOrgFeatures(orgId) + return features.some(feature => feature.name === ORG_FEATURE_RUNTIME) + } catch (err) { + aioConsoleLogger.debug(err) + return false + } + } + + /** + * Filter orgs to those that can be used by Developer Console App Builder flows. + * + * @param {Array<{id: string, type: string}>} orgs organizations + * @returns {Promise>} selectable organizations + */ + async getSelectableOrgs (orgs) { + const checks = await Promise.all(orgs.map(async org => { + if (org.type === ORG_TYPE_ENTERPRISE) { + return true + } + if (org.type === ORG_TYPE_DEVELOPER) { + return this.hasRuntimeFeature(org.id) + } + return false + })) + return orgs.filter((_, i) => checks[i]) + } + /** * Output JSON data * diff --git a/src/commands/console/org/list.js b/src/commands/console/org/list.js index fe712fb..ec255df 100644 --- a/src/commands/console/org/list.js +++ b/src/commands/console/org/list.js @@ -13,7 +13,6 @@ governing permissions and limitations under the License. const aioConsoleLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-console:org:list', { provider: 'debug' }) const { Flags } = require('@oclif/core') const { table } = require('../../../utils/table') -const { ORG_TYPE_ENTERPRISE } = require('../../../config') const ConsoleCommand = require('../index') @@ -48,9 +47,8 @@ class ListCommand extends ConsoleCommand { */ async getConsoleOrgs () { const response = await this.consoleCLI.getOrganizations() - const orgs = response - // Filter enterprise orgs - .filter(org => org.type === ORG_TYPE_ENTERPRISE) + const selectableOrgs = await this.getSelectableOrgs(response) + const orgs = selectableOrgs // Omit props .map(({ id, code, name }) => ({ id, code, name })) diff --git a/src/commands/console/org/select.js b/src/commands/console/org/select.js index d48431e..11fcb53 100644 --- a/src/commands/console/org/select.js +++ b/src/commands/console/org/select.js @@ -41,7 +41,7 @@ class SelectCommand extends ConsoleCommand { } async selectOrgInteractive (preSelectedOrgIdOrCode) { - const orgs = await this.consoleCLI.getOrganizations() + const orgs = await this.getSelectableOrgs(await this.consoleCLI.getOrganizations()) const org = await this.consoleCLI.promptForSelectOrganization( orgs, { orgId: preSelectedOrgIdOrCode, orgCode: preSelectedOrgIdOrCode } diff --git a/src/config.js b/src/config.js index fd3b9df..e44feef 100644 --- a/src/config.js +++ b/src/config.js @@ -10,6 +10,8 @@ governing permissions and limitations under the License. */ const ORG_TYPE_ENTERPRISE = 'entp' +const ORG_TYPE_DEVELOPER = 'developer' +const ORG_FEATURE_RUNTIME = 'RUNTIME' const API_KEYS = { prod: 'aio-cli-console-auth', @@ -28,9 +30,17 @@ const OPEN_URLS = { stage: 'https://developer-stage.adobe.com/console/projects' } +const CONSOLE_API_URLS = { + prod: 'https://developer.adobe.com', + stage: 'https://developer-stage.adobe.com' +} + module.exports = { ORG_TYPE_ENTERPRISE, + ORG_TYPE_DEVELOPER, + ORG_FEATURE_RUNTIME, CONFIG_KEYS, API_KEYS, - OPEN_URLS + OPEN_URLS, + CONSOLE_API_URLS } diff --git a/test/__fixtures__/org/list.json b/test/__fixtures__/org/list.json index 56cf81b..5f3e497 100644 --- a/test/__fixtures__/org/list.json +++ b/test/__fixtures__/org/list.json @@ -13,5 +13,10 @@ "id": 3, "code": "CODE03", "name": "ORG03" + }, + { + "id": 4, + "code": "CODE04", + "name": "ORGFOURXX" } ] diff --git a/test/__fixtures__/org/list.txt b/test/__fixtures__/org/list.txt index 1614b8c..6cbf866 100644 --- a/test/__fixtures__/org/list.txt +++ b/test/__fixtures__/org/list.txt @@ -1,5 +1,6 @@ Org ID Code Org Name - ────── ────── ──────── + ────── ────── ───────── 1 CODE01 ORG01 2 CODE02 ORG02 3 CODE03 ORG03 + 4 CODE04 ORGFOURXX diff --git a/test/__fixtures__/org/list.yml b/test/__fixtures__/org/list.yml index 3b15001..f66a439 100644 --- a/test/__fixtures__/org/list.yml +++ b/test/__fixtures__/org/list.yml @@ -7,4 +7,7 @@ - id: 3 code: CODE03 name: ORG03 +- id: 4 + code: CODE04 + name: ORGFOURXX diff --git a/test/commands/console/index.test.js b/test/commands/console/index.test.js index a9e3f9e..2352dd8 100644 --- a/test/commands/console/index.test.js +++ b/test/commands/console/index.test.js @@ -226,5 +226,74 @@ describe('ConsoleCommand', () => { command.clearConfig() expect(config.delete).toHaveBeenCalledWith(CONFIG_KEYS.CONSOLE) }) + + test('getOrgFeatures', async () => { + command.cliEnv = STAGE_ENV + command.apiKey = API_KEYS[STAGE_ENV] + command.accessToken = 'token' + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([{ name: 'RUNTIME' }]) + }) + + await expect(command.getOrgFeatures('304327')).resolves.toEqual([{ name: 'RUNTIME' }]) + expect(global.fetch).toHaveBeenCalledWith('https://developer-stage.adobe.com/console/api/organizations/304327/features', { + headers: { + accept: 'application/json', + authorization: 'Bearer token', + 'x-api-key': API_KEYS[STAGE_ENV] + } + }) + }) + + test('getOrgFeatures returns empty list for non-ok response', async () => { + command.cliEnv = PROD_ENV + command.apiKey = API_KEYS[PROD_ENV] + command.accessToken = 'token' + global.fetch = jest.fn().mockResolvedValue({ ok: false }) + + await expect(command.getOrgFeatures('304327')).resolves.toEqual([]) + }) + + test('getOrgFeatures falls back to prod url for unknown env', async () => { + command.cliEnv = 'unknown-env' + command.apiKey = API_KEYS[PROD_ENV] + command.accessToken = 'token' + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([]) + }) + + await expect(command.getOrgFeatures('304327')).resolves.toEqual([]) + expect(global.fetch).toHaveBeenCalledWith('https://developer.adobe.com/console/api/organizations/304327/features', expect.any(Object)) + }) + + test('hasRuntimeFeature', async () => { + command.getOrgFeatures = jest.fn().mockResolvedValue([{ name: 'RUNTIME' }]) + await expect(command.hasRuntimeFeature('304327')).resolves.toBe(true) + + command.getOrgFeatures = jest.fn().mockResolvedValue([{ name: 'OTHER' }]) + await expect(command.hasRuntimeFeature('304327')).resolves.toBe(false) + }) + + test('hasRuntimeFeature returns false when feature lookup fails', async () => { + command.getOrgFeatures = jest.fn().mockRejectedValue(new Error('bad feature lookup')) + await expect(command.hasRuntimeFeature('304327')).resolves.toBe(false) + }) + + test('getSelectableOrgs includes enterprise orgs and developer orgs with Runtime', async () => { + command.hasRuntimeFeature = jest.fn(orgId => Promise.resolve(orgId === 'developer-runtime')) + const orgs = [ + { id: 'enterprise', type: 'entp' }, + { id: 'developer-runtime', type: 'developer' }, + { id: 'developer-no-runtime', type: 'developer' }, + { id: 'other', type: 'other' } + ] + + await expect(command.getSelectableOrgs(orgs)).resolves.toEqual([ + { id: 'enterprise', type: 'entp' }, + { id: 'developer-runtime', type: 'developer' } + ]) + }) }) }) diff --git a/test/commands/console/org/list.test.js b/test/commands/console/org/list.test.js index 1f17f0d..9e35ec9 100644 --- a/test/commands/console/org/list.test.js +++ b/test/commands/console/org/list.test.js @@ -18,7 +18,9 @@ function setDefaultMockConsoleCLI () { { id: 1, code: 'CODE01', name: 'ORG01', type: 'entp' }, { id: 2, code: 'CODE02', name: 'ORG02', type: 'entp' }, { id: 3, code: 'CODE03', name: 'ORG03', type: 'entp' }, - { id: 3, code: 'CODE03', name: 'ORG03', type: 'not_entp' } + { id: 4, code: 'CODE04', name: 'ORGFOURXX', type: 'developer' }, + { id: 5, code: 'CODE05', name: 'ORG05', type: 'not_entp' }, + { id: 6, code: 'CODE06', name: 'ORG06', type: 'developer' } ]) } jest.mock('@adobe/aio-cli-lib-console', () => ({ @@ -31,6 +33,10 @@ const ListCommand = require('../../../../src/commands/console/org/list') let command beforeEach(() => { setDefaultMockConsoleCLI() + global.fetch = jest.fn(url => Promise.resolve({ + ok: true, + json: () => Promise.resolve(url.endsWith('/4/features') ? [{ name: 'RUNTIME' }] : []) + })) command = new ListCommand([]) }) @@ -64,7 +70,7 @@ describe('console:org:list', () => { }) test('should return list of orgs', async () => { await expect(command.run()).resolves.not.toThrow() - expect(stdout.output).toMatchFixture('org/list.txt') + expect(stdout.output).toMatchFixtureIgnoreWhite('org/list.txt') }) test('should return list of orgs as json', async () => { diff --git a/test/commands/console/org/select.test.js b/test/commands/console/org/select.test.js index 0c9a80b..529e3f0 100644 --- a/test/commands/console/org/select.test.js +++ b/test/commands/console/org/select.test.js @@ -16,9 +16,13 @@ const orgs = [ { id: '1', code: 'CODE01', name: 'ORG01', type: 'entp' }, { id: '2', code: 'CODE02', name: 'ORG02', type: 'entp' }, { id: '3', code: 'CODE03', name: 'ORG03', type: 'entp' }, - { id: '33', code: 'CODE03', name: 'ORG03', type: 'not_entp' } + { id: '4', code: 'CODE04', name: 'ORG04', type: 'developer' }, + { id: '6', code: 'CODE06', name: 'ORG06', type: 'developer' }, + { id: '33', code: 'CODE33', name: 'ORG33', type: 'not_entp' } ] +const selectableOrgs = orgs.slice(0, 4) const selectedOrg = { id: '1', code: 'CODE01', name: 'ORG01', type: 'entp' } +const selectedDeveloperOrg = { id: '4', code: 'CODE04', name: 'ORG04', type: 'developer' } /** @private */ function setDefaultMockConsoleCLI () { mockConsoleCLIInstance.getOrganizations = jest.fn().mockResolvedValue(orgs) @@ -36,6 +40,10 @@ let command beforeEach(() => { command = new SelectCommand([]) setDefaultMockConsoleCLI() + global.fetch = jest.fn(url => Promise.resolve({ + ok: true, + json: () => Promise.resolve(url.endsWith('/4/features') ? [{ name: 'RUNTIME' }] : []) + })) config.set.mockReset() config.delete.mockReset() }) @@ -73,7 +81,7 @@ describe('console:org:select', () => { test('should select the provided org code', async () => { command.argv = [selectedOrg.code] await expect(command.run()).resolves.not.toThrow() - expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(orgs, { orgCode: selectedOrg.code, orgId: selectedOrg.code }) + expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(selectableOrgs, { orgCode: selectedOrg.code, orgId: selectedOrg.code }) // stores the org configuration expect(config.set).toHaveBeenCalledWith('console.org', { code: selectedOrg.code, id: selectedOrg.id, name: selectedOrg.name }) expect(config.delete).toHaveBeenCalledWith('console.project') @@ -82,7 +90,7 @@ describe('console:org:select', () => { test('should select the provided org id', async () => { command.argv = [selectedOrg.id] await expect(command.run()).resolves.not.toThrow() - expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(orgs, { orgCode: selectedOrg.id, orgId: selectedOrg.id }) + expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(selectableOrgs, { orgCode: selectedOrg.id, orgId: selectedOrg.id }) // stores the org configuration expect(config.set).toHaveBeenCalledWith('console.org', { code: selectedOrg.code, id: selectedOrg.id, name: selectedOrg.name }) expect(config.delete).toHaveBeenCalledWith('console.project') @@ -91,13 +99,23 @@ describe('console:org:select', () => { test('should prompt for selection if no org is provided', async () => { command.argv = [] await expect(command.run()).resolves.not.toThrow() - expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(orgs, { orgCode: undefined, orgId: undefined }) + expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(selectableOrgs, { orgCode: undefined, orgId: undefined }) // stores the org configuration expect(config.set).toHaveBeenCalledWith('console.org', { code: selectedOrg.code, id: selectedOrg.id, name: selectedOrg.name }) expect(config.delete).toHaveBeenCalledWith('console.project') expect(config.delete).toHaveBeenCalledWith('console.workspace') }) + test('should select the provided developer org id', async () => { + mockConsoleCLIInstance.promptForSelectOrganization.mockResolvedValue(selectedDeveloperOrg) + command.argv = [selectedDeveloperOrg.id] + await expect(command.run()).resolves.not.toThrow() + expect(mockConsoleCLIInstance.promptForSelectOrganization).toHaveBeenCalledWith(selectableOrgs, { orgCode: selectedDeveloperOrg.id, orgId: selectedDeveloperOrg.id }) + expect(config.set).toHaveBeenCalledWith('console.org', { code: selectedDeveloperOrg.code, id: selectedDeveloperOrg.id, name: selectedDeveloperOrg.name }) + expect(config.delete).toHaveBeenCalledWith('console.project') + expect(config.delete).toHaveBeenCalledWith('console.workspace') + }) + test('throw Error retrieving Orgs', async () => { mockConsoleCLIInstance.getOrganizations.mockRejectedValue(new Error('error org')) await expect(command.run()).rejects.toThrow(new Error('error org'))