diff --git a/src/index.js b/src/index.js index 85f7c80..82a45c8 100644 --- a/src/index.js +++ b/src/index.js @@ -50,11 +50,13 @@ export function invalidateCache () { * @param {string} params.clientSecret - The client secret * @param {string} params.orgId - The organization ID * @param {string[]} [params.scopes=[]] - Array of scopes to request - * @param {string} [imsEnv='prod'] - The IMS environment ('prod' or 'stage') + * @param {string} [imsEnv] - The IMS environment ('prod' or 'stage'); when omitted or falsy, uses stage if __OW_NAMESPACE starts with 'development-', else prod * @returns {Promise} Promise that resolves with the token response * @throws {Error} If there's an error getting the access token */ -export async function generateAccessToken (params, imsEnv = 'prod' ) { +export async function generateAccessToken (params, imsEnv) { + imsEnv = imsEnv || (ioRuntimeStageNamespace() ? 'stage' : 'prod') + const credentials = getAndValidateCredentials(params) const credAndEnv = { ...credentials, env: imsEnv } @@ -74,3 +76,7 @@ export async function generateAccessToken (params, imsEnv = 'prod' ) { return token } + +function ioRuntimeStageNamespace () { + return process.env.__OW_NAMESPACE && process.env.__OW_NAMESPACE.startsWith('development-') +} diff --git a/test/AuthErrors.test.js b/test/AuthErrors.test.js deleted file mode 100644 index c1a3eb7..0000000 --- a/test/AuthErrors.test.js +++ /dev/null @@ -1,85 +0,0 @@ -/* -Copyright 2025 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. -*/ - -import { describe, test, expect } from 'vitest' -import { codes, messages } from '../src/errors.js' - -describe('AuthErrors', () => { - test('codes object is defined', () => { - expect(codes).toBeDefined() - expect(typeof codes).toBe('object') - }) - - test('messages Map is defined', () => { - expect(messages).toBeDefined() - expect(messages instanceof Map).toBe(true) - }) - - test('IMS_TOKEN_ERROR error code exists', () => { - expect(codes.IMS_TOKEN_ERROR).toBeDefined() - expect(typeof codes.IMS_TOKEN_ERROR).toBe('function') - }) - - test('MISSING_PARAMETERS error code exists', () => { - expect(codes.MISSING_PARAMETERS).toBeDefined() - expect(typeof codes.MISSING_PARAMETERS).toBe('function') - }) - - test('GENERIC_ERROR error code exists', () => { - expect(codes.GENERIC_ERROR).toBeDefined() - expect(typeof codes.GENERIC_ERROR).toBe('function') - }) - - test('can instantiate MISSING_PARAMETERS error', () => { - const error = new codes.MISSING_PARAMETERS({ - messageValues: 'clientId, clientSecret' - }) - - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('MISSING_PARAMETERS') - expect(error.sdk).toBe('AuthSDK') - expect(error.message).toContain('clientId, clientSecret') - }) - - test('can instantiate IMS_TOKEN_ERROR error', () => { - const error = new codes.IMS_TOKEN_ERROR({ - messageValues: 'Invalid credentials', - sdkDetails: { statusCode: 401 } - }) - - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('IMS_TOKEN_ERROR') - expect(error.sdk).toBe('AuthSDK') - expect(error.message).toContain('Invalid credentials') - expect(error.sdkDetails.statusCode).toBe(401) - }) - - test('can instantiate GENERIC_ERROR error', () => { - const error = new codes.GENERIC_ERROR({ - messageValues: 'Connection timeout' - }) - - expect(error).toBeInstanceOf(Error) - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('GENERIC_ERROR') - expect(error.sdk).toBe('AuthSDK') - expect(error.message).toContain('Connection timeout') - }) - - test('error messages are stored in messages Map', () => { - expect(messages.size).toBeGreaterThan(0) - expect(messages.has('MISSING_PARAMETERS')).toBe(true) - expect(messages.has('IMS_TOKEN_ERROR')).toBe(true) - expect(messages.has('GENERIC_ERROR')).toBe(true) - }) -}) diff --git a/test/ims.test.js b/test/ims.test.js new file mode 100644 index 0000000..703f622 --- /dev/null +++ b/test/ims.test.js @@ -0,0 +1,568 @@ +/* +Copyright 2025 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. +*/ + +import { describe, test, expect, beforeEach, vi } from 'vitest' +import { getAccessTokenByClientCredentials, getAndValidateCredentials } from '../src/ims.js' +import { codes } from '../src/errors.js' + +// Mock fetch globally +global.fetch = vi.fn() + +// Helper to create mock headers +const createMockHeaders = (headers = {}) => ({ + get: (name) => headers[name.toLowerCase()] || null +}) + +describe('getAccessTokenByClientCredentials', () => { + const validParams = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid', 'AdobeID'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + test('is a function', () => { + expect(typeof getAccessTokenByClientCredentials).toBe('function') + }) + + test('successfully gets an access token with valid credentials', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const result = await getAccessTokenByClientCredentials(validParams) + + expect(result).toEqual(mockSuccessResponse) + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: expect.stringContaining('grant_type=client_credentials') + }) + ) + }) + + test('sends correct form data in request body', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await getAccessTokenByClientCredentials(validParams) + + const callArgs = fetch.mock.calls[0][1] + const body = callArgs.body + + expect(body).toContain('grant_type=client_credentials') + expect(body).toContain(`client_id=${validParams.clientId}`) + expect(body).toContain(`client_secret=${validParams.clientSecret}`) + expect(body).toContain(`org_id=${validParams.orgId}`) + // URLSearchParams encodes commas as %2C + // Scopes are sorted, so we check for both scopes + expect(body).toContain('scope=') + expect(body).toContain('AdobeID') + expect(body).toContain('openid') + }) + + test('works with empty scopes array', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const paramsWithoutScopes = { ...validParams, scopes: [] } + await getAccessTokenByClientCredentials(paramsWithoutScopes) + + const callArgs = fetch.mock.calls[0][1] + const body = callArgs.body + + expect(body).not.toContain('scope=') + }) + + test('works without scopes parameter', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + const { scopes, ...paramsWithoutScopes } = validParams + await getAccessTokenByClientCredentials(paramsWithoutScopes) + + const callArgs = fetch.mock.calls[0][1] + const body = callArgs.body + + expect(body).not.toContain('scope=') + }) + + test('uses production IMS URL by default', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await getAccessTokenByClientCredentials(validParams) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses stage IMS URL when env is stage', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await getAccessTokenByClientCredentials({ ...validParams, env: 'stage' }) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses production IMS URL when env is prod', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await getAccessTokenByClientCredentials({ ...validParams, env: 'prod' }) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('uses production IMS URL for unknown env values', async () => { + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await getAccessTokenByClientCredentials({ ...validParams, env: 'invalid' }) + + expect(fetch).toHaveBeenCalledWith( + 'https://ims-na1.adobelogin.com/ims/token/v2', + expect.any(Object) + ) + }) + + test('throws IMS_TOKEN_ERROR when API returns error response', async () => { + const mockErrorResponse = { + ok: false, + status: 400, + statusText: 'Bad Request', + headers: createMockHeaders({ 'x-debug-id': 'debug-123' }), + json: async () => ({ + error: 'invalid_client', + error_description: 'Invalid client credentials' + }) + } + + fetch.mockResolvedValue(mockErrorResponse) + + await expect(getAccessTokenByClientCredentials(validParams)) + .rejects + .toThrow(codes.IMS_TOKEN_ERROR) + + // Additional validation + let error + try { + await getAccessTokenByClientCredentials(validParams) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('IMS_TOKEN_ERROR') + expect(error.message).toContain('Invalid client credentials') + expect(error.sdkDetails.statusCode).toBe(400) + }) + + test('throws IMS_TOKEN_ERROR on 401 unauthorized', async () => { + fetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: createMockHeaders(), + json: async () => ({ + error: 'unauthorized', + error_description: 'Authentication failed' + }) + }) + + await expect(getAccessTokenByClientCredentials(validParams)) + .rejects + .toThrow(codes.IMS_TOKEN_ERROR) + }) + + test('throws IMS_TOKEN_ERROR with HTTP status when no error fields present', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + headers: createMockHeaders(), + json: async () => ({}) // Empty response without error or error_description + }) + + // Verify it falls back to HTTP status message + let error + try { + await getAccessTokenByClientCredentials(validParams) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('IMS_TOKEN_ERROR') + expect(error.message).toContain('HTTP 503') + expect(error.sdkDetails.statusCode).toBe(503) + }) + + test('throws GENERIC_ERROR on network failure', async () => { + fetch.mockRejectedValue(new Error('Network connection failed')) + + await expect(getAccessTokenByClientCredentials(validParams)) + .rejects + .toThrow(codes.GENERIC_ERROR) + + // Additional validation + let error + try { + await getAccessTokenByClientCredentials(validParams) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('GENERIC_ERROR') + expect(error.message).toContain('Network connection failed') + }) + + test('throws GENERIC_ERROR on timeout', async () => { + fetch.mockRejectedValueOnce(new Error('Request timeout')) + + await expect(getAccessTokenByClientCredentials(validParams)) + .rejects + .toThrow(codes.GENERIC_ERROR) + }) + + test('includes sdkDetails in error for debugging', async () => { + const mockErrorResponse = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: createMockHeaders({ 'x-debug-id': 'debug-500' }), + json: async () => ({ + error: 'server_error' + }) + } + + fetch.mockResolvedValue(mockErrorResponse) + + await expect(getAccessTokenByClientCredentials(validParams)) + .rejects + .toThrow(codes.IMS_TOKEN_ERROR) + + // Additional validation + let error + try { + await getAccessTokenByClientCredentials(validParams) + } catch (e) { + error = e + } + expect(error.sdkDetails).toBeDefined() + expect(error.sdkDetails.clientId).toBe(validParams.clientId) + expect(error.sdkDetails.orgId).toBe(validParams.orgId) + expect(error.sdkDetails.statusCode).toBe(500) + expect(error.sdkDetails.xDebugId).toBe('debug-500') + }) +}) + +describe('getAccessTokenByClientCredentials - no caching', () => { + const validParams = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + test('does not cache - always makes fresh API calls', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + // First call + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(1) + + // Second call - should make another API call (no cache) + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(2) + + // Third call - should make another API call (no cache) + await getAccessTokenByClientCredentials(validParams) + expect(fetch).toHaveBeenCalledTimes(3) + }) +}) + +describe('getAndValidateCredentials', () => { + test('is a function', () => { + expect(typeof getAndValidateCredentials).toBe('function') + }) + + test('validates and returns credentials with camelCase params', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const result = getAndValidateCredentials(params) + + expect(result).toEqual({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + }) + }) + + test('validates and returns credentials with snake_case params', () => { + const params = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + org_id: 'test-org-id', + scopes: ['openid'] + } + + const result = getAndValidateCredentials(params) + + expect(result).toEqual({ + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + }) + }) + + test('prefers camelCase over snake_case when both are provided', () => { + const params = { + clientId: 'camel-client-id', + client_id: 'snake-client-id', + clientSecret: 'camel-secret', + client_secret: 'snake-secret', + orgId: 'camel-org-id', + org_id: 'snake-org-id' + } + + const result = getAndValidateCredentials(params) + + expect(result.clientId).toBe('camel-client-id') + expect(result.clientSecret).toBe('camel-secret') + expect(result.orgId).toBe('camel-org-id') + }) + + test('defaults scopes to empty array when not provided', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id' + } + + const result = getAndValidateCredentials(params) + + expect(result.scopes).toEqual([]) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is null', () => { + expect(() => getAndValidateCredentials(null)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + + let error + try { + getAndValidateCredentials(null) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('BAD_CREDENTIALS_FORMAT') + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is undefined', () => { + expect(() => getAndValidateCredentials(undefined)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is an array', () => { + expect(() => getAndValidateCredentials(['test'])) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is a string', () => { + expect(() => getAndValidateCredentials('test')) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws BAD_CREDENTIALS_FORMAT when params is a number', () => { + expect(() => getAndValidateCredentials(123)) + .toThrow(codes.BAD_CREDENTIALS_FORMAT) + }) + + test('throws MISSING_PARAMETERS when clientId is missing', () => { + const params = { + clientSecret: 'test-client-secret', + orgId: 'test-org-id' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS when clientSecret is missing', () => { + const params = { + clientId: 'test-client-id', + orgId: 'test-org-id' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS when orgId is missing', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.MISSING_PARAMETERS) + }) + + test('throws MISSING_PARAMETERS with all missing params listed', () => { + let error + try { + getAndValidateCredentials({}) + } catch (e) { + error = e + } + + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('MISSING_PARAMETERS') + expect(error.message).toContain('clientId') + expect(error.message).toContain('clientSecret') + expect(error.message).toContain('orgId') + }) + + test('throws BAD_SCOPES_FORMAT when scopes is a string', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: 'openid' + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + + let error + try { + getAndValidateCredentials(params) + } catch (e) { + error = e + } + expect(error.name).toBe('AuthSDKError') + expect(error.code).toBe('BAD_SCOPES_FORMAT') + expect(error.sdkDetails.scopesType).toBe('string') + }) + + test('throws BAD_SCOPES_FORMAT when scopes is an object', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: { scope: 'openid' } + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + }) + + test('throws BAD_SCOPES_FORMAT when scopes is a number', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: 123 + } + + expect(() => getAndValidateCredentials(params)) + .toThrow(codes.BAD_SCOPES_FORMAT) + }) + + test('accepts scopes as an array', () => { + const params = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid', 'profile'] + } + + const result = getAndValidateCredentials(params) + expect(result.scopes).toEqual(['openid', 'profile']) + }) +}) diff --git a/test/index.test.js b/test/index.test.js index 33eaf37..13697aa 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -9,9 +9,8 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { describe, test, expect, beforeEach, vi } from 'vitest' +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest' import { generateAccessToken, invalidateCache } from '../src/index.js' -import { getAccessTokenByClientCredentials, getAndValidateCredentials } from '../src/ims.js' import { codes } from '../src/errors.js' // Mock fetch globally @@ -22,304 +21,6 @@ const createMockHeaders = (headers = {}) => ({ get: (name) => headers[name.toLowerCase()] || null }) -describe('getAccessTokenByClientCredentials', () => { - const validParams = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid', 'AdobeID'] - } - - const mockSuccessResponse = { - access_token: 'test-access-token', - token_type: 'bearer', - expires_in: 86399 - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - test('is a function', () => { - expect(typeof getAccessTokenByClientCredentials).toBe('function') - }) - - test('successfully gets an access token with valid credentials', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - const result = await getAccessTokenByClientCredentials(validParams) - - expect(result).toEqual(mockSuccessResponse) - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith( - 'https://ims-na1.adobelogin.com/ims/token/v2', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: expect.stringContaining('grant_type=client_credentials') - }) - ) - }) - - test('sends correct form data in request body', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - await getAccessTokenByClientCredentials(validParams) - - const callArgs = fetch.mock.calls[0][1] - const body = callArgs.body - - expect(body).toContain('grant_type=client_credentials') - expect(body).toContain(`client_id=${validParams.clientId}`) - expect(body).toContain(`client_secret=${validParams.clientSecret}`) - expect(body).toContain(`org_id=${validParams.orgId}`) - // URLSearchParams encodes commas as %2C - // Scopes are sorted, so we check for both scopes - expect(body).toContain('scope=') - expect(body).toContain('AdobeID') - expect(body).toContain('openid') - }) - - test('works with empty scopes array', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - const paramsWithoutScopes = { ...validParams, scopes: [] } - await getAccessTokenByClientCredentials(paramsWithoutScopes) - - const callArgs = fetch.mock.calls[0][1] - const body = callArgs.body - - expect(body).not.toContain('scope=') - }) - - test('works without scopes parameter', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - const { scopes, ...paramsWithoutScopes } = validParams - await getAccessTokenByClientCredentials(paramsWithoutScopes) - - const callArgs = fetch.mock.calls[0][1] - const body = callArgs.body - - expect(body).not.toContain('scope=') - }) - - test('uses production IMS URL by default', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - await getAccessTokenByClientCredentials(validParams) - - expect(fetch).toHaveBeenCalledWith( - 'https://ims-na1.adobelogin.com/ims/token/v2', - expect.any(Object) - ) - }) - - test('uses stage IMS URL when env is stage', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - await getAccessTokenByClientCredentials({ ...validParams, env: 'stage' }) - - expect(fetch).toHaveBeenCalledWith( - 'https://ims-na1-stg1.adobelogin.com/ims/token/v2', - expect.any(Object) - ) - }) - - test('uses production IMS URL when env is prod', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - await getAccessTokenByClientCredentials({ ...validParams, env: 'prod' }) - - expect(fetch).toHaveBeenCalledWith( - 'https://ims-na1.adobelogin.com/ims/token/v2', - expect.any(Object) - ) - }) - - test('uses production IMS URL for unknown env values', async () => { - fetch.mockResolvedValueOnce({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - await getAccessTokenByClientCredentials({ ...validParams, env: 'invalid' }) - - expect(fetch).toHaveBeenCalledWith( - 'https://ims-na1.adobelogin.com/ims/token/v2', - expect.any(Object) - ) - }) - - test('throws IMS_TOKEN_ERROR when API returns error response', async () => { - const mockErrorResponse = { - ok: false, - status: 400, - statusText: 'Bad Request', - headers: createMockHeaders({ 'x-debug-id': 'debug-123' }), - json: async () => ({ - error: 'invalid_client', - error_description: 'Invalid client credentials' - }) - } - - fetch.mockResolvedValue(mockErrorResponse) - - await expect(getAccessTokenByClientCredentials(validParams)) - .rejects - .toThrow(codes.IMS_TOKEN_ERROR) - - // Additional validation - let error - try { - await getAccessTokenByClientCredentials(validParams) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('IMS_TOKEN_ERROR') - expect(error.message).toContain('Invalid client credentials') - expect(error.sdkDetails.statusCode).toBe(400) - }) - - test('throws IMS_TOKEN_ERROR on 401 unauthorized', async () => { - fetch.mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - headers: createMockHeaders(), - json: async () => ({ - error: 'unauthorized', - error_description: 'Authentication failed' - }) - }) - - await expect(getAccessTokenByClientCredentials(validParams)) - .rejects - .toThrow(codes.IMS_TOKEN_ERROR) - }) - - test('throws IMS_TOKEN_ERROR with HTTP status when no error fields present', async () => { - fetch.mockResolvedValue({ - ok: false, - status: 503, - statusText: 'Service Unavailable', - headers: createMockHeaders(), - json: async () => ({}) // Empty response without error or error_description - }) - - // Verify it falls back to HTTP status message - let error - try { - await getAccessTokenByClientCredentials(validParams) - } catch (e) { - error = e - } - expect(error).toBeDefined() - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('IMS_TOKEN_ERROR') - expect(error.message).toContain('HTTP 503') - expect(error.sdkDetails.statusCode).toBe(503) - }) - - test('throws GENERIC_ERROR on network failure', async () => { - fetch.mockRejectedValue(new Error('Network connection failed')) - - await expect(getAccessTokenByClientCredentials(validParams)) - .rejects - .toThrow(codes.GENERIC_ERROR) - - // Additional validation - let error - try { - await getAccessTokenByClientCredentials(validParams) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('GENERIC_ERROR') - expect(error.message).toContain('Network connection failed') - }) - - test('throws GENERIC_ERROR on timeout', async () => { - fetch.mockRejectedValueOnce(new Error('Request timeout')) - - await expect(getAccessTokenByClientCredentials(validParams)) - .rejects - .toThrow(codes.GENERIC_ERROR) - }) - - test('includes sdkDetails in error for debugging', async () => { - const mockErrorResponse = { - ok: false, - status: 500, - statusText: 'Internal Server Error', - headers: createMockHeaders({ 'x-debug-id': 'debug-500' }), - json: async () => ({ - error: 'server_error' - }) - } - - fetch.mockResolvedValue(mockErrorResponse) - - await expect(getAccessTokenByClientCredentials(validParams)) - .rejects - .toThrow(codes.IMS_TOKEN_ERROR) - - // Additional validation - let error - try { - await getAccessTokenByClientCredentials(validParams) - } catch (e) { - error = e - } - expect(error.sdkDetails).toBeDefined() - expect(error.sdkDetails.clientId).toBe(validParams.clientId) - expect(error.sdkDetails.orgId).toBe(validParams.orgId) - expect(error.sdkDetails.statusCode).toBe(500) - expect(error.sdkDetails.xDebugId).toBe('debug-500') - }) -}) - describe('generateAccessToken', () => { const validParams = { clientId: 'test-client-id', @@ -363,46 +64,6 @@ describe('generateAccessToken', () => { }) }) -describe('getAccessTokenByClientCredentials - no caching', () => { - const validParams = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid'] - } - - const mockSuccessResponse = { - access_token: 'test-access-token', - token_type: 'bearer', - expires_in: 86399 - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - test('does not cache - always makes fresh API calls', async () => { - fetch.mockResolvedValue({ - ok: true, - status: 200, - headers: createMockHeaders(), - json: async () => mockSuccessResponse - }) - - // First call - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(1) - - // Second call - should make another API call (no cache) - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(2) - - // Third call - should make another API call (no cache) - await getAccessTokenByClientCredentials(validParams) - expect(fetch).toHaveBeenCalledTimes(3) - }) -}) - describe('generateAccessToken - with caching', () => { const validParams = { clientId: 'test-client-id', @@ -575,214 +236,6 @@ describe('invalidateCache', () => { }) }) -describe('getAndValidateCredentials', () => { - test('is a function', () => { - expect(typeof getAndValidateCredentials).toBe('function') - }) - - test('validates and returns credentials with camelCase params', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid'] - } - - const result = getAndValidateCredentials(params) - - expect(result).toEqual({ - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid'] - }) - }) - - test('validates and returns credentials with snake_case params', () => { - const params = { - client_id: 'test-client-id', - client_secret: 'test-client-secret', - org_id: 'test-org-id', - scopes: ['openid'] - } - - const result = getAndValidateCredentials(params) - - expect(result).toEqual({ - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid'] - }) - }) - - test('prefers camelCase over snake_case when both are provided', () => { - const params = { - clientId: 'camel-client-id', - client_id: 'snake-client-id', - clientSecret: 'camel-secret', - client_secret: 'snake-secret', - orgId: 'camel-org-id', - org_id: 'snake-org-id' - } - - const result = getAndValidateCredentials(params) - - expect(result.clientId).toBe('camel-client-id') - expect(result.clientSecret).toBe('camel-secret') - expect(result.orgId).toBe('camel-org-id') - }) - - test('defaults scopes to empty array when not provided', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id' - } - - const result = getAndValidateCredentials(params) - - expect(result.scopes).toEqual([]) - }) - - test('throws BAD_CREDENTIALS_FORMAT when params is null', () => { - expect(() => getAndValidateCredentials(null)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) - - let error - try { - getAndValidateCredentials(null) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('BAD_CREDENTIALS_FORMAT') - }) - - test('throws BAD_CREDENTIALS_FORMAT when params is undefined', () => { - expect(() => getAndValidateCredentials(undefined)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) - }) - - test('throws BAD_CREDENTIALS_FORMAT when params is an array', () => { - expect(() => getAndValidateCredentials(['test'])) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) - }) - - test('throws BAD_CREDENTIALS_FORMAT when params is a string', () => { - expect(() => getAndValidateCredentials('test')) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) - }) - - test('throws BAD_CREDENTIALS_FORMAT when params is a number', () => { - expect(() => getAndValidateCredentials(123)) - .toThrow(codes.BAD_CREDENTIALS_FORMAT) - }) - - test('throws MISSING_PARAMETERS when clientId is missing', () => { - const params = { - clientSecret: 'test-client-secret', - orgId: 'test-org-id' - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS when clientSecret is missing', () => { - const params = { - clientId: 'test-client-id', - orgId: 'test-org-id' - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS when orgId is missing', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret' - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.MISSING_PARAMETERS) - }) - - test('throws MISSING_PARAMETERS with all missing params listed', () => { - let error - try { - getAndValidateCredentials({}) - } catch (e) { - error = e - } - - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('MISSING_PARAMETERS') - expect(error.message).toContain('clientId') - expect(error.message).toContain('clientSecret') - expect(error.message).toContain('orgId') - }) - - test('throws BAD_SCOPES_FORMAT when scopes is a string', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: 'openid' - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) - - let error - try { - getAndValidateCredentials(params) - } catch (e) { - error = e - } - expect(error.name).toBe('AuthSDKError') - expect(error.code).toBe('BAD_SCOPES_FORMAT') - expect(error.sdkDetails.scopesType).toBe('string') - }) - - test('throws BAD_SCOPES_FORMAT when scopes is an object', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: { scope: 'openid' } - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) - }) - - test('throws BAD_SCOPES_FORMAT when scopes is a number', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: 123 - } - - expect(() => getAndValidateCredentials(params)) - .toThrow(codes.BAD_SCOPES_FORMAT) - }) - - test('accepts scopes as an array', () => { - const params = { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - orgId: 'test-org-id', - scopes: ['openid', 'profile'] - } - - const result = getAndValidateCredentials(params) - expect(result.scopes).toEqual(['openid', 'profile']) - }) -}) - describe('generateAccessToken - BAD_SCOPES_FORMAT error', () => { test('throws BAD_SCOPES_FORMAT when scopes is a string', async () => { const params = { @@ -966,6 +419,116 @@ describe('generateAccessToken - imsEnv parameter', () => { }) }) +describe('generateAccessToken - imsEnv default (ioRuntimeStageNamespace)', () => { + const validParams = { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + orgId: 'test-org-id', + scopes: ['openid'] + } + + const mockSuccessResponse = { + access_token: 'test-access-token', + token_type: 'bearer', + expires_in: 86399 + } + + const originalNamespace = process.env.__OW_NAMESPACE + + beforeEach(() => { + vi.clearAllMocks() + invalidateCache() + }) + + afterEach(() => { + if (originalNamespace !== undefined) { + process.env.__OW_NAMESPACE = originalNamespace + } else { + delete process.env.__OW_NAMESPACE + } + }) + + test('uses stage IMS URL when imsEnv not provided and __OW_NAMESPACE starts with development-', async () => { + process.env.__OW_NAMESPACE = 'development-12345' + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://ims-na1-stg1.adobelogin.com/ims/token/v2') + }) + + test('uses prod IMS URL when imsEnv not provided and __OW_NAMESPACE is unset', async () => { + delete process.env.__OW_NAMESPACE + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://ims-na1.adobelogin.com/ims/token/v2') + }) + + test('uses prod IMS URL when imsEnv not provided and __OW_NAMESPACE does not start with development-', async () => { + process.env.__OW_NAMESPACE = 'production-12345' + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://ims-na1.adobelogin.com/ims/token/v2') + }) + + test('uses stage IMS URL when imsEnv is falsy (empty string) and __OW_NAMESPACE starts with development-', async () => { + process.env.__OW_NAMESPACE = 'development-xyz' + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams, '') + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://ims-na1-stg1.adobelogin.com/ims/token/v2') + }) + + test('uses prod IMS URL when imsEnv is falsy (empty string) and __OW_NAMESPACE is unset', async () => { + delete process.env.__OW_NAMESPACE + + fetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: createMockHeaders(), + json: async () => mockSuccessResponse + }) + + await generateAccessToken(validParams, '') + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://ims-na1.adobelogin.com/ims/token/v2') + }) +}) + describe('generateAccessToken - BAD_CREDENTIALS_FORMAT error', () => { test('throws BAD_CREDENTIALS_FORMAT when params is null', async () => { await expect(generateAccessToken(null))