diff --git a/package.json b/package.json index 142799e..5162428 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "dependencies": { "@adobe/aio-lib-core-config": "^5", "@adobe/aio-lib-core-logging": "^3", - "@adobe/aio-lib-db": "^0.1.0-beta.8", + "@adobe/aio-lib-db": "^0.2.0-beta.1", "@adobe/aio-lib-env": "^3.0.1", + "@adobe/aio-lib-ims": "^8.0.0", "@adobe/aio-lib-state": "^5.3.0", "@inquirer/prompts": "^5", "@oclif/core": "^4", diff --git a/src/DBBaseCommand.js b/src/DBBaseCommand.js index 97d921c..f4783d8 100644 --- a/src/DBBaseCommand.js +++ b/src/DBBaseCommand.js @@ -12,10 +12,11 @@ governing permissions and limitations under the License. import { BaseCommand } from './BaseCommand.js' import config from '@adobe/aio-lib-core-config' -import { CONFIG_RUNTIME_AUTH, CONFIG_RUNTIME_NAMESPACE } from './constants/global.js' +import { CONFIG_RUNTIME_NAMESPACE } from './constants/global.js' import { AVAILABLE_REGIONS, CONFIG_DB_ENDPOINT, CONFIG_DB_REGION, DEFAULT_REGION } from './constants/db.js' import { Flags } from '@oclif/core' import { getCliEnv } from '@adobe/aio-lib-env' +import { getAccessToken } from './utils/authHelper.js' export class DBBaseCommand extends BaseCommand { async init () { @@ -33,12 +34,27 @@ export class DBBaseCommand extends BaseCommand { async initializeDBClient () { try { const region = this.flags?.region || config.get(CONFIG_DB_REGION) || DEFAULT_REGION + const runtimeNamespace = config.get(CONFIG_RUNTIME_NAMESPACE) + if (!runtimeNamespace) { + this.error( + `Database commands require App Builder project configuration. +Please make sure the 'AIO_RUNTIME_NAMESPACE' environment variable is configured` + ) + } + + const authToken = await getAccessToken() + if (!authToken) { + this.error( + 'Database commands require IMS token for authentication. Please make sure you have the required Ims credentials configured in environment variables.' + ) + } + // Get database configuration const dbConfig = { ow: { - namespace: config.get(CONFIG_RUNTIME_NAMESPACE), - auth: config.get(CONFIG_RUNTIME_AUTH) + namespace: runtimeNamespace }, + token: authToken, region } @@ -48,14 +64,6 @@ export class DBBaseCommand extends BaseCommand { this.error(`Invalid region '${region}' for the ${getCliEnv()} environment, must be one of: ${allowedRegions.join(', ')}`) } - // Validate required configuration - if (!(dbConfig.ow.namespace && dbConfig.ow.auth)) { - this.error( - `Database commands require App Builder project configuration. -Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment variables are configured.` - ) - } - const endpointOverride = config.get(CONFIG_DB_ENDPOINT) if (endpointOverride) { process.env.AIO_DB_ENDPOINT = endpointOverride @@ -70,7 +78,7 @@ Please make sure the 'AIO_RUNTIME_NAMESPACE' and 'AIO_RUNTIME_AUTH' environment this.debugLogger?.info?.('Initializing DB client with config:', { namespace: dbConfig.ow.namespace, region: dbConfig.region, - hasAuth: !!dbConfig.ow.auth + hasToken: !!dbConfig.token }) // Initialize the database client diff --git a/src/constants/global.js b/src/constants/global.js index b9714f1..20d56ec 100644 --- a/src/constants/global.js +++ b/src/constants/global.js @@ -12,3 +12,5 @@ governing permissions and limitations under the License. export const CONFIG_RUNTIME_NAMESPACE = 'runtime.namespace' export const CONFIG_RUNTIME_AUTH = 'runtime.auth' +export const CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER = 'dummy@techacct.adobe.com' +export const CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER = 'dummy@techacct.adobe.com' diff --git a/src/utils/authHelper.js b/src/utils/authHelper.js new file mode 100644 index 0000000..d403970 --- /dev/null +++ b/src/utils/authHelper.js @@ -0,0 +1,81 @@ +/* +Copyright 2026 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. +*/ + +/** + * Helper to get the IMS access token using Adobe I/O SDK + * @returns {string} The IMS access token + */ +import aioLibIms from '@adobe/aio-lib-ims' +import { CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER, CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER } from '../constants/global.js' +const normalizeArrayString = (value) => { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? JSON.stringify(parsed) : '[]' + } catch { + const items = value.split(',').map((entry) => entry.trim()).filter(Boolean) + return JSON.stringify(items) + } +} + +const buildAuthConfig = () => { + const clientId = process.env.IMS_OAUTH_S2S_CLIENT_ID + const clientSecret = process.env.IMS_OAUTH_S2S_CLIENT_SECRET + const orgId = process.env.IMS_OAUTH_S2S_ORG_ID + const scopes = process.env.IMS_OAUTH_S2S_SCOPES + const technicalAccountEmail = process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_EMAIL || CONFIG_IMS_TECHNICAL_ACCOUNT_EMAIL_PLACEHOLDER + const technicalAccountId = process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_ID || CONFIG_IMS_TECHNICAL_ACCOUNT_ID_PLACEHOLDER + + if (!clientId || !clientSecret || !orgId || !scopes) { + throw new Error('Missing required credentials. Please set IMS_OAUTH_S2S_CLIENT_ID, IMS_OAUTH_S2S_CLIENT_SECRET, IMS_OAUTH_S2S_ORG_ID, and IMS_OAUTH_S2S_SCOPES environment variables.') + } + + return { + client_id: clientId, + client_secrets: normalizeArrayString(clientSecret), + ims_org_id: orgId, + scopes: normalizeArrayString(scopes), + technical_account_email: technicalAccountEmail, + technical_account_id: technicalAccountId + } +} + +/** + * Get or generate an IMS access token using OAuth Server-to-Server credentials. + * @returns {Promise} The access token + */ +export async function getAccessToken () { + const runtimeNamespace = process.env.AIO_RUNTIME_NAMESPACE + + if (!runtimeNamespace) { + throw new Error('Runtime namespace is required. Please set AIO_RUNTIME_NAMESPACE environment variable.') + } + + try { + const authConfig = buildAuthConfig() + const imsContextName = `oauth_s2s_${runtimeNamespace}` + + const { context, getToken } = aioLibIms + await context.set(imsContextName, authConfig, true) + const accessToken = await getToken(imsContextName) + + if (!accessToken) { + throw new Error('Failed to generate access token. Please verify your credentials.') + } + + return accessToken + } catch (error) { + if (error instanceof Error) { + throw error + } + throw new Error('Failed to retrieve access token: Unknown error') + } +} diff --git a/test/DBBaseCommand.test.js b/test/DBBaseCommand.test.js index c118af7..07760b2 100644 --- a/test/DBBaseCommand.test.js +++ b/test/DBBaseCommand.test.js @@ -10,13 +10,29 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import { expect, jest } from '@jest/globals' -import { DBBaseCommand } from '../src/DBBaseCommand.js' -import { BaseCommand } from '../src/BaseCommand.js' import { AVAILABLE_REGIONS, DEFAULT_REGION } from '../src/constants/db.js' const mockInit = global.mockDBInit const mockDbInstance = global.mockDBInstance +// Mock getAccessToken for DBBaseCommand tests +const mockGetAccessToken = jest.fn() +jest.unstable_mockModule('../src/utils/authHelper.js', () => ({ + getAccessToken: mockGetAccessToken +})) + +let DBBaseCommand +let BaseCommand + +beforeAll(async () => { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const dbModule = await import('../src/DBBaseCommand.js') + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const baseModule = await import('../src/BaseCommand.js') + DBBaseCommand = dbModule.DBBaseCommand + BaseCommand = baseModule.BaseCommand +}) + describe('prototype', () => { test('extends BaseCommand', () => { expect(DBBaseCommand.prototype instanceof BaseCommand).toBe(true) @@ -41,17 +57,22 @@ describe('init', () => { command.config = { runHook: jest.fn().mockResolvedValue({}) } + + // Reset and configure getAccessToken mock + mockGetAccessToken.mockReset() + mockGetAccessToken.mockResolvedValue('test-token') }) test('successful initialization', async () => { command.argv = [] await command.init() + expect(mockGetAccessToken).toHaveBeenCalled() expect(mockInit).toHaveBeenCalledWith({ ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: DEFAULT_REGION }) expect(command.db).toBe(mockDbInstance) @@ -109,16 +130,16 @@ describe('init', () => { global.fakeConfig['runtime.namespace'] = null await expect(command.init()).rejects.toThrow( - 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' environment variable is configured' ) }) - test('missing auth', async () => { + test('missing token', async () => { command.argv = [] - global.fakeConfig['runtime.auth'] = null + mockGetAccessToken.mockResolvedValue(null) await expect(command.init()).rejects.toThrow( - 'Database commands require App Builder project configuration.\nPlease make sure the \'AIO_RUNTIME_NAMESPACE\' and \'AIO_RUNTIME_AUTH\' environment variables are configured.' + 'Database commands require IMS token for authentication' ) }) @@ -166,7 +187,7 @@ describe('initializeDBClient', () => { expect.objectContaining({ namespace: global.fakeConfig['runtime.namespace'], region: 'amer', - hasAuth: true + hasToken: true }) ) }) @@ -193,9 +214,9 @@ describe('configuration', () => { test('dbConfig contains expected properties', async () => { const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: 'amer' } @@ -212,9 +233,9 @@ describe('configuration', () => { global.fakeConfig['db.endpoint'] = 'https://custom.db.com' const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: global.fakeConfig['db.region'] } @@ -229,9 +250,9 @@ describe('configuration', () => { test('dbConfig uses region flag', async () => { const expectedConfig = { ow: { - namespace: global.fakeConfig['runtime.namespace'], - auth: global.fakeConfig['runtime.auth'] + namespace: global.fakeConfig['runtime.namespace'] }, + token: 'test-token', region: 'emea' } diff --git a/test/commands/app/db/provision.test.js b/test/commands/app/db/provision.test.js index 5c4bd16..27fbf2c 100644 --- a/test/commands/app/db/provision.test.js +++ b/test/commands/app/db/provision.test.js @@ -277,7 +277,11 @@ describe('run', () => { test('provision with custom region', async () => { command.argv = ['--region', 'emea'] await command.init() - expect(global.mockDBInit).toHaveBeenCalledWith({ ow: expect.any(Object), region: 'emea' }) + expect(global.mockDBInit).toHaveBeenCalledWith({ + ow: expect.any(Object), + token: expect.any(String), + region: 'emea' + }) mockProvisionStatus.mockRejectedValue(new Error('not found')) mockConfirm.mockResolvedValue(true) diff --git a/test/jest.setup.js b/test/jest.setup.js index 0d95630..a32ada8 100644 --- a/test/jest.setup.js +++ b/test/jest.setup.js @@ -79,6 +79,21 @@ jest.unstable_mockModule('@adobe/aio-lib-env', () => ({ })) global.getCliEnvMock = () => mockEnv +// mock ims +const imsContextSetMock = jest.fn() +const imsGetTokenMock = jest.fn() +const imsModule = { context: { set: imsContextSetMock }, getToken: imsGetTokenMock } +jest.unstable_mockModule('@adobe/aio-lib-ims', () => { + if (global.__ims_no_default) { + return imsModule + } + return { ...imsModule, default: imsModule } +}) +global.getImsMock = () => ({ + imsContextSetMock, + imsGetTokenMock +}) + beforeEach(() => { // trap console log stdout.start() @@ -91,10 +106,22 @@ beforeEach(() => { 'runtime.auth': 'auth', 'state.endpoint': null, 'db.endpoint': null, - 'db.region': null + 'db.region': null, + 'ims.contexts.test-namespace': { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + org_id: 'test-org-id', + scopes: ['test-scope'] + }, + 'ims.contexts.test-namespace.token': 'test-access-token' } delete process.env.AIO_STATE_ENDPOINT delete process.env.AIO_DB_ENDPOINT + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'test-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'test-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'test-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = '["test-scope"]' mockInit.mockReset() mockInit.mockResolvedValue(mockInstance) @@ -108,5 +135,15 @@ beforeEach(() => { mockEnv.mockReset() mockEnv.mockReturnValue('prod') + + config.set = (key, value) => { + global.fakeConfig[key] = value + } + + const imsMocks = global.getImsMock() + imsMocks.imsContextSetMock.mockReset() + imsMocks.imsGetTokenMock.mockReset() + imsMocks.imsGetTokenMock.mockResolvedValue('test-access-token') + global.__ims_no_default = false }) afterEach(() => { stdout.stop(); stderr.stop() }) diff --git a/test/utils/authHelper.test.js b/test/utils/authHelper.test.js new file mode 100644 index 0000000..d84219c --- /dev/null +++ b/test/utils/authHelper.test.js @@ -0,0 +1,148 @@ +/* +Copyright 2026 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 loadGetAccessToken = async () => { + // eslint-disable-next-line node/no-unsupported-features/es-syntax + const authModule = await import('../../src/utils/authHelper.js') + return authModule.getAccessToken +} + +let getAccessToken + +beforeAll(async () => { + getAccessToken = await loadGetAccessToken() +}) + +describe('getAccessToken()', () => { + beforeEach(() => { + const imsMocks = global.getImsMock() + imsMocks.imsContextSetMock.mockReset() + imsMocks.imsGetTokenMock.mockReset() + delete process.env.AIO_RUNTIME_NAMESPACE + delete process.env.IMS_OAUTH_S2S_CLIENT_ID + delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET + delete process.env.IMS_OAUTH_S2S_ORG_ID + delete process.env.IMS_OAUTH_S2S_SCOPES + delete process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_EMAIL + delete process.env.IMS_OAUTH_S2S_TECHNICAL_ACCOUNT_ID + }) + + test('throws when runtime namespace is missing', async () => { + await expect(getAccessToken()).rejects.toThrow('Runtime namespace is required. Please set AIO_RUNTIME_NAMESPACE environment variable.') + }) + + test('builds auth config from environment and returns token (json scopes)', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = '["scope-x","scope-y"]' + + const imsMocks = global.getImsMock() + imsMocks.imsGetTokenMock.mockResolvedValue('token-string') + + await expect(getAccessToken()).resolves.toBe('token-string') + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'oauth_s2s_test-namespace', + expect.objectContaining({ + client_id: 'env-client-id', + client_secrets: '["env-client-secret"]', + ims_org_id: 'env-org-id', + scopes: '["scope-x","scope-y"]', + technical_account_email: 'dummy@techacct.adobe.com', + technical_account_id: 'dummy@techacct.adobe.com' + }), + true + ) + }) + + test('builds auth config from environment and returns token (csv scopes)', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a, scope-b' + + const imsMocks = global.getImsMock() + imsMocks.imsGetTokenMock.mockResolvedValue('token') + + await expect(getAccessToken()).resolves.toBe('token') + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'oauth_s2s_test-namespace', + expect.objectContaining({ + scopes: '["scope-a","scope-b"]' + }), + true + ) + }) + + test('builds auth config with empty scopes array when scope list is null', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'null' + + const imsMocks = global.getImsMock() + imsMocks.imsGetTokenMock.mockResolvedValue('empty-scope-token') + + await expect(getAccessToken()).resolves.toBe('empty-scope-token') + expect(imsMocks.imsContextSetMock).toHaveBeenCalledWith( + 'oauth_s2s_test-namespace', + expect.objectContaining({ + scopes: '[]' + }), + true + ) + }) + + test('throws when required credentials are missing', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + delete process.env.IMS_OAUTH_S2S_CLIENT_ID + delete process.env.IMS_OAUTH_S2S_CLIENT_SECRET + delete process.env.IMS_OAUTH_S2S_ORG_ID + delete process.env.IMS_OAUTH_S2S_SCOPES + + await expect(getAccessToken()).rejects.toThrow( + 'Missing required credentials.' + ) + }) + + test('throws when token response is empty', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a' + + const imsMocks = global.getImsMock() + imsMocks.imsGetTokenMock.mockResolvedValue(null) + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to generate access token. Please verify your credentials.' + ) + }) + + test('throws unknown error when IMS client rejects with non-error', async () => { + process.env.AIO_RUNTIME_NAMESPACE = 'test-namespace' + process.env.IMS_OAUTH_S2S_CLIENT_ID = 'env-client-id' + process.env.IMS_OAUTH_S2S_CLIENT_SECRET = 'env-client-secret' + process.env.IMS_OAUTH_S2S_ORG_ID = 'env-org-id' + process.env.IMS_OAUTH_S2S_SCOPES = 'scope-a' + + const imsMocks = global.getImsMock() + imsMocks.imsGetTokenMock.mockRejectedValue('failed to get token') + + await expect(getAccessToken()).rejects.toThrow( + 'Failed to retrieve access token: Unknown error' + ) + }) +})