From fc1e570d08072426932805d5c16bcae0a412cd95 Mon Sep 17 00:00:00 2001 From: Shazron Abdullah Date: Tue, 9 Jan 2024 22:52:10 +0800 Subject: [PATCH] Revert "feat: ACNA-2584 - add Adobe App Builder State Store (#134)" This reverts commit 9afdc73737a0841f73e511a3497b6d87fcf5bfa7. --- e2e/.env.example | 4 - e2e/e2e.js | 94 +++--- e2e/e2e.md | 4 +- index.js | 2 +- jest.config.js | 10 +- lib/AdobeState.js | 371 --------------------- lib/StateStore.js | 172 ++++++++++ lib/{StateError.js => StateStoreError.js} | 34 +- lib/constants.js | 39 --- lib/impl/CosmosStateStore.js | 185 +++++++++++ lib/init.js | 78 ++++- lib/utils.js | 40 +-- package.json | 8 +- test/AdobeState.test.js | 382 ---------------------- test/StateStore.test.js | 153 +++++++++ test/impl/CosmosStateStore.test.js | 361 ++++++++++++++++++++ test/init.test.js | 117 +++++-- test/utils.test.js | 43 --- 18 files changed, 1110 insertions(+), 987 deletions(-) delete mode 100644 e2e/.env.example delete mode 100644 lib/AdobeState.js create mode 100644 lib/StateStore.js rename lib/{StateError.js => StateStoreError.js} (59%) delete mode 100644 lib/constants.js create mode 100644 lib/impl/CosmosStateStore.js delete mode 100644 test/AdobeState.test.js create mode 100644 test/StateStore.test.js create mode 100644 test/impl/CosmosStateStore.test.js delete mode 100644 test/utils.test.js diff --git a/e2e/.env.example b/e2e/.env.example deleted file mode 100644 index 1f72db1..0000000 --- a/e2e/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -TEST_NAMESPACE_1= -TEST_AUTH_1= -TEST_NAMESPACE_2= -TEST_AUTH_2= diff --git a/e2e/e2e.js b/e2e/e2e.js index 90e674b..480bd18 100644 --- a/e2e/e2e.js +++ b/e2e/e2e.js @@ -13,24 +13,24 @@ governing permissions and limitations under the License. /* ************* NOTE 1: these tests must be run sequentially, jest does it by default within a SINGLE file ************* */ /* ************* NOTE 2: requires env vars TEST_AUTH_1, TEST_NS_1 and TEST_AUTH_2, TEST_NS_2 for 2 different namespaces. ************* */ -const path = require('node:path') - -// load .env values in the e2e folder, if any -require('dotenv').config({ path: path.join(__dirname, '.env') }) - -const { MAX_TTL_SECONDS } = require('../lib/constants') const stateLib = require('../index') +const { codes } = require('../lib/StateStoreError') const testKey = 'e2e_test_state_key' jest.setTimeout(30000) // thirty seconds per test +beforeEach(() => { + expect.hasAssertions() +}) + const initStateEnv = async (n = 1) => { delete process.env.__OW_API_KEY delete process.env.__OW_NAMESPACE process.env.__OW_API_KEY = process.env[`TEST_AUTH_${n}`] process.env.__OW_NAMESPACE = process.env[`TEST_NAMESPACE_${n}`] - const state = await stateLib.init() + // 1. init will fetch credentials from the tvm using ow creds + const state = await stateLib.init() // { tvm: { cacheFile: false } } // keep cache for better perf? // make sure we delete the testKey, note that delete might fail as it is an op under test await state.delete(testKey) return state @@ -44,25 +44,18 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { delete process.env.__OW_NAMESPACE process.env.__OW_API_KEY = process.env.TEST_AUTH_1 process.env.__OW_NAMESPACE = process.env.TEST_NAMESPACE_1 + 'bad' - let expectedError try { - const store = await stateLib.init() - await store.get('something') + await stateLib.init() } catch (e) { - expectedError = e - } - - expect(expectedError).toBeDefined() - expect(expectedError instanceof Error).toBeTruthy() - expect({ name: expectedError.name, code: expectedError.code, message: expectedError.message, sdkDetails: expectedError.sdkDetails }) - .toEqual(expect.objectContaining({ - name: 'AdobeStateLibError', + expect({ name: e.name, code: e.code, message: e.message, sdkDetails: e.sdkDetails }).toEqual(expect.objectContaining({ + name: 'StateLibError', code: 'ERROR_BAD_CREDENTIALS' })) + } }) - test('key-value basic test on one key with string value: put, get, delete, any, deleteAll', async () => { + test('key-value basic test on one key with string value: get, write, get, delete, get', async () => { const state = await initStateEnv() const testValue = 'a string' @@ -70,57 +63,63 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { expect(await state.get(testKey)).toEqual(undefined) expect(await state.put(testKey, testValue)).toEqual(testKey) expect(await state.get(testKey)).toEqual(expect.objectContaining({ value: testValue })) - expect(await state.delete(testKey)).toEqual(testKey) + expect(await state.delete(testKey, testValue)).toEqual(testKey) + expect(await state.get(testKey)).toEqual(undefined) + }) + + test('key-value basic test on one key with object value: get, write, get, delete, get', async () => { + const state = await initStateEnv() + + const testValue = { a: 'fake', object: { with: { multiple: 'layers' }, that: { dreams: { of: { being: 'real' } } } } } + expect(await state.get(testKey)).toEqual(undefined) - expect(await state.any()).toEqual(false) expect(await state.put(testKey, testValue)).toEqual(testKey) - expect(await state.any()).toEqual(true) - expect(await state.deleteAll()).toEqual(true) + expect(await state.get(testKey)).toEqual(expect.objectContaining({ value: testValue })) + expect(await state.delete(testKey, testValue)).toEqual(testKey) expect(await state.get(testKey)).toEqual(undefined) - expect(await state.any()).toEqual(false) }) test('time-to-live tests: write w/o ttl, get default ttl, write with ttl, get, get after ttl', async () => { const state = await initStateEnv() - const testValue = 'test value' - let res, resTime + const testValue = { an: 'object' } // 1. test default ttl = 1 day expect(await state.put(testKey, testValue)).toEqual(testKey) - res = await state.get(testKey) - resTime = new Date(res.expiration).getTime() - expect(resTime).toBeLessThanOrEqual(new Date(Date.now() + 86400000).getTime()) // 86400000 ms = 1 day - expect(resTime).toBeGreaterThanOrEqual(new Date(Date.now() + 86400000 - 10000).getTime()) // give more or less 10 seconds clock skew + request time + let res = await state.get(testKey) + expect(new Date(res.expiration).getTime()).toBeLessThanOrEqual(new Date(Date.now() + 86400000).getTime()) // 86400000 ms = 1 day + expect(new Date(res.expiration).getTime()).toBeGreaterThanOrEqual(new Date(Date.now() + 86400000 - 10000).getTime()) // give more or less 10 seconds clock skew + request time - // 2. test max ttl - const nowPlus365Days = new Date(MAX_TTL_SECONDS).getTime() + // 2. test infinite ttl expect(await state.put(testKey, testValue, { ttl: -1 })).toEqual(testKey) - res = await state.get(testKey) - resTime = new Date(res.expiration).getTime() - expect(resTime).toBeGreaterThanOrEqual(nowPlus365Days) + expect(await state.get(testKey)).toEqual(expect.objectContaining({ expiration: null })) // 3. test that after ttl object is deleted expect(await state.put(testKey, testValue, { ttl: 2 })).toEqual(testKey) res = await state.get(testKey) expect(new Date(res.expiration).getTime()).toBeLessThanOrEqual(new Date(Date.now() + 2000).getTime()) - await waitFor(3000) // give it one more sec - ttl is not so precise + await waitFor(3000) // give it one more sec - azure ttl is not so precise expect(await state.get(testKey)).toEqual(undefined) }) test('throw error when get/put with invalid keys', async () => { - const invalidKey = 'some/invalid/key' + const invalidChars = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' " + const invalidKey = 'invalid/key' const state = await initStateEnv() - await expect(state.put(invalidKey, 'testValue')).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value') - await expect(state.get(invalidKey)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key') + await expect(state.put(invalidKey, 'testValue')).rejects.toThrow(new codes.ERROR_BAD_REQUEST({ + messageValues: [invalidChars] + })) + await expect(state.get(invalidKey)).rejects.toThrow(new codes.ERROR_BAD_REQUEST({ + messageValues: [invalidChars] + })) }) test('isolation tests: get, write, delete on same key for two namespaces do not interfere', async () => { const state1 = await initStateEnv(1) const state2 = await initStateEnv(2) - const testValue1 = 'one value' - const testValue2 = 'some other value' + const testValue1 = { an: 'object' } + const testValue2 = { another: 'dummy' } // 1. test that ns2 cannot get state in ns1 await state1.put(testKey, testValue1) @@ -140,21 +139,16 @@ describe('e2e tests using OpenWhisk credentials (as env vars)', () => { test('error value bigger than 2MB test', async () => { const state = await initStateEnv() + const bigValue = ('a').repeat(1024 * 1024 * 2 + 1) - let expectedError try { await state.put(testKey, bigValue) } catch (e) { - expectedError = e - } - - expect(expectedError).toBeDefined() - expect(expectedError instanceof Error).toBeTruthy() - expect({ name: expectedError.name, code: expectedError.code, message: expectedError.message, sdkDetails: expectedError.sdkDetails }) - .toEqual(expect.objectContaining({ - name: 'AdobeStateLibError', + expect({ name: e.name, code: e.code, message: e.message, sdkDetails: e.sdkDetails }).toEqual(expect.objectContaining({ + name: 'StateLibError', code: 'ERROR_PAYLOAD_TOO_LARGE' })) + } }) }) diff --git a/e2e/e2e.md b/e2e/e2e.md index fb5f4bf..70f7ff4 100644 --- a/e2e/e2e.md +++ b/e2e/e2e.md @@ -3,11 +3,9 @@ ## Requirements - To run the test you'll need two OpenWhisk namespaces. Please set the credentials for those in the following env - variables in an .env file: + variables: - `TEST_NAMESPACE_1, TEST_AUTH_1, TEST_NAMESPACE_2, TEST_AUTH_2` -Copy the `.env.example` to your own `.env` in this folder. - ## Run `npm run e2e` diff --git a/index.js b/index.js index e8f638c..b62f727 100644 --- a/index.js +++ b/index.js @@ -9,5 +9,5 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -require('./lib/AdobeState') +require('./lib/StateStore') module.exports = require('./lib/init') diff --git a/jest.config.js b/jest.config.js index 28273b4..6dabe5d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,13 +18,5 @@ module.exports = { collectCoverageFrom: [ 'index.js', 'lib/**/*.js' - ], - coverageThreshold: { - global: { - branches: 100, - lines: 100, - statements: 100, - functions: 100 - } - } + ] } diff --git a/lib/AdobeState.js b/lib/AdobeState.js deleted file mode 100644 index 2b0081c..0000000 --- a/lib/AdobeState.js +++ /dev/null @@ -1,371 +0,0 @@ -/* -Copyright 2024 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 { codes, logAndThrow } = require('./StateError') -const utils = require('./utils') -const cloneDeep = require('lodash.clonedeep') -const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) -const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') -const url = require('node:url') -const { getCliEnv } = require('@adobe/aio-lib-env') -const { ADOBE_STATE_STORE_ENDPOINT, REGEX_PATTERN_STORE_KEY } = require('./constants') -const Ajv = require('ajv') - -/* *********************************** typedefs *********************************** */ - -/** - * AdobeStateCredentials - * - * @typedef AdobeStateCredentials - * @type {object} - * @property {string} namespace the state store namespace - * @property {string} apikey the state store api key - */ - -/** - * AdobeState put options - * - * @typedef AdobeStatePutOptions - * @type {object} - * @property {number} ttl time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for no expiry. A - * value of 0 sets default. - */ - -/** - * AdobeState get return object - * - * @typedef AdobeStateGetReturnValue - * @type {object} - * @property {string|null} expiration ISO date string of expiration time for the key-value pair, if the ttl is infinite - * expiration=null - * @property {any} value the value set by put - */ - -/* *********************************** helpers *********************************** */ - -/** - * Validates json according to a schema. - * - * @param {object} schema the AJV schema - * @param {object} data the json data to test - * @returns {object} the result - */ -function validate (schema, data) { - const ajv = new Ajv({ allErrors: true }) - const validate = ajv.compile(schema) - const valid = validate(data) - - return { valid, errors: validate.errors } -} - -// eslint-disable-next-line jsdoc/require-jsdoc -async function _wrap (promise, params) { - let response - try { - response = await promise - logger.debug('response', response) - // reuse code in exception handler, for any other network exceptions - if (!response.ok) { - // no exception on 404 - if (response.status === 404) { - return null - } else { - const e = new Error(response.statusText) - e.status = response.status - e.internal = response - throw e - } - } - } catch (e) { - const status = e.status || e.code - const copyParams = cloneDeep(params) - logger.debug(`got internal error with status ${status}: ${e.message} `) - switch (status) { - case 401: - return logAndThrow(new codes.ERROR_UNAUTHORIZED({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) - case 403: - return logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) - case 413: - return logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) - case 429: - return logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) - default: - return logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${status}`], sdkDetails: { ...cloneDeep(params), _internal: e.internal } })) - } - } - return response -} - -/** - * @abstract - * @class AdobeState - * @classdesc Cloud State Management - * @hideconstructor - */ -class AdobeState { - /** - * Creates an instance of AdobeState. - * - * @memberof AdobeState - * @private - * @param {string} namespace the namespace for the Adobe State Store - * @param {string} apikey the apikey for the Adobe State Store - */ - constructor (namespace, apikey) { - /** @private */ - this.namespace = namespace - /** @private */ - this.apikey = apikey - /** @private */ - this.endpoint = ADOBE_STATE_STORE_ENDPOINT[getCliEnv()] - /** @private */ - this.fetchRetry = new HttpExponentialBackoff() - } - - /** - * Creates a request url. - * - * @private - * @param {string} key the key of the state store - * @param {object} queryObject the query variables to send - * @returns {string} the constructed request url - */ - createRequestUrl (key, queryObject = {}) { - let requestUrl - - if (key) { - requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}/data/${key}`) - } else { - requestUrl = new url.URL(`${this.endpoint}/v1/containers/${this.namespace}`) - } - - // add the query params - requestUrl.search = (new url.URLSearchParams(queryObject)).toString() - return requestUrl.toString() - } - - /** - * Get Authorization headers. - * - * @private - * @returns {string} the authorization headers - */ - getAuthorizationHeaders () { - return { - Authorization: `Basic ${this.apikey}` - } - } - - /** - * Instantiates and returns a new AdobeState object - * - * @static - * @param {AdobeStateCredentials} credentials the credential object - * @returns {Promise} a new AdobeState instance - * @memberof AdobeState - * @override - * @private - */ - static async init (credentials = {}) { - // include ow environment vars to credentials - if (!credentials.namespace && !credentials.apikey) { - credentials.namespace = process.env.__OW_NAMESPACE - credentials.apikey = process.env.__OW_API_KEY - } - - const cloned = utils.withHiddenFields(credentials, ['apikey']) - logger.debug(`init AdobeState with ${JSON.stringify(cloned, null, 2)}`) - - const schema = { - type: 'object', - properties: { - apikey: { type: 'string' }, - namespace: { type: 'string' } - }, - required: ['apikey', 'namespace'] - } - - const { valid, errors } = validate(schema, credentials) - if (!valid) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: ['apikey and/or namespace is missing', JSON.stringify(errors, null, 2)], - sdkDetails: cloned - })) - } - - return new AdobeState(credentials.namespace, credentials.apikey) - } - - /* **************************** ADOBE STATE STORE OPERATORS ***************************** */ - - /** - * Retrieves the state value for given key. - * If the key doesn't exist returns undefined. - * - * @param {string} key state key identifier - * @returns {Promise} get response holding value and additional info - * @memberof AdobeState - */ - async get (key) { - logger.debug(`get '${key}'`) - - const schema = { - type: 'object', - properties: { - key: { - type: 'string', - pattern: REGEX_PATTERN_STORE_KEY - } - } - } - - const { valid, errors } = validate(schema, { key }) - if (!valid) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: ['invalid key', JSON.stringify(errors, null, 2)], - sdkDetails: { key, errors } - })) - } - - const requestOptions = { - method: 'GET', - headers: { - ...this.getAuthorizationHeaders() - } - } - logger.debug('get', requestOptions) - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) - const response = await _wrap(promise, { key }) - if (response) { - // we only expect string values - return response.json() - } - } - - /** - * Creates or updates a state key-value pair - * - * @param {string} key state key identifier - * @param {string} value state value - * @param {AdobeStatePutOptions} [options={}] put options - * @returns {Promise} key - * @memberof AdobeState - */ - async put (key, value, options = {}) { - logger.debug(`put '${key}' with options ${JSON.stringify(options)}`) - - const schema = { - type: 'object', - properties: { - key: { - type: 'string', - pattern: REGEX_PATTERN_STORE_KEY - }, - value: { - type: 'string' - } - } - } - - const { valid, errors } = validate(schema, { key, value }) - if (!valid) { - logAndThrow(new codes.ERROR_BAD_ARGUMENT({ - messageValues: ['invalid key and/or value', JSON.stringify(errors, null, 2)], - sdkDetails: { key, value, options, errors } - })) - } - - const { ttl } = options - const queryParams = ttl ? { ttl } : {} - const requestOptions = { - method: 'PUT', - headers: { - ...this.getAuthorizationHeaders(), - 'Content-Type': 'application/octet-stream' - }, - body: value - } - - logger.debug('put', requestOptions) - - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key, queryParams), requestOptions) - await _wrap(promise, { key, value, ...options }) - return key - } - - /** - * Deletes a state key-value pair - * - * @param {string} key state key identifier - * @returns {Promise} key of deleted state or `null` if state does not exists - * @memberof AdobeState - */ - async delete (key) { - logger.debug(`delete '${key}'`) - - const requestOptions = { - method: 'DELETE', - headers: { - ...this.getAuthorizationHeaders() - } - } - - logger.debug('delete', requestOptions) - - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(key), requestOptions) - const ret = await _wrap(promise, { key }) - return ret && key - } - - /** - * Deletes all key-values - * - * @returns {Promise} true if deleted, false if not - * @memberof StateStore - */ - async deleteAll () { - const requestOptions = { - method: 'DELETE', - headers: { - ...this.getAuthorizationHeaders() - } - } - - logger.debug('deleteAll', requestOptions) - - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) - const response = await _wrap(promise, {}) - return response !== null - } - - /** - * There exists key-values. - * - * @returns {Promise} true if exists, false if not - * @memberof StateStore - */ - async any () { - const requestOptions = { - method: 'HEAD', - headers: { - ...this.getAuthorizationHeaders() - } - } - - logger.debug('any', requestOptions) - - const promise = this.fetchRetry.exponentialBackoff(this.createRequestUrl(), requestOptions) - const response = await _wrap(promise, {}) - return response !== null - } -} - -module.exports = { AdobeState } diff --git a/lib/StateStore.js b/lib/StateStore.js new file mode 100644 index 0000000..36399d4 --- /dev/null +++ b/lib/StateStore.js @@ -0,0 +1,172 @@ +/* +Copyright 2019 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 { codes, logAndThrow } = require('./StateStoreError') +const joi = require('joi') +const cloneDeep = require('lodash.clonedeep') +const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) + +/* *********************************** typedefs *********************************** */ + +/** + * StateStore put options + * + * @typedef StateStorePutOptions + * @type {object} + * @property {number} ttl time-to-live for key-value pair in seconds, defaults to 24 hours (86400s). Set to < 0 for no expiry. A + * value of 0 sets default. + */ + +/** + * StateStore get return object + * + * @typedef StateStoreGetReturnValue + * @type {object} + * @property {string|null} expiration ISO date string of expiration time for the key-value pair, if the ttl is infinite + * expiration=null + * @property {any} value the value set by put + */ + +/* *********************************** helpers *********************************** */ + +// eslint-disable-next-line jsdoc/require-jsdoc +function throwNotImplemented (methodName) { + logAndThrow(new codes.ERROR_NOT_IMPLEMENTED({ messageValues: [methodName] })) +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateInput (input, schema, details) { + const validation = schema.validate(input) + if (validation.error) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: [validation.error.message], + sdkDetails: cloneDeep(details) + })) + } +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateKey (key, details, label = 'key') { + validateInput(key, joi.string().label(label).required().regex(/[?#/\\]/, { invert: true }).messages({ + 'string.pattern.invert.base': 'Key cannot contain ?, #, /, or \\' + }), details) +} + +// eslint-disable-next-line jsdoc/require-jsdoc +function validateValue (value, details, label = 'value') { + validateInput(value, joi.any().label(label), details) // make it .required() ? +} + +/** + * @abstract + * @class StateStore + * @classdesc Cloud State Management + * @hideconstructor + */ +class StateStore { + /* **************************** CONSTRUCTOR/INIT TO IMPLEMENT ***************************** */ + + /** + * Creates an instance of StateStore. + * + * @param {boolean} _isTest set this to true to allow construction + * @memberof StateStore + * @private + * @abstract + */ + constructor (_isTest) { if (new.target === StateStore && !_isTest) throwNotImplemented('StateStore') } + // marked as private to hide from jsdoc, wrapped by index.js init + /** + * Instantiates and returns a new StateStore object + * + * @static + * @param {object} credentials abstract credential object + * @returns {Promise} a new StateStore instance + * @memberof StateStore + * @private + */ + static async init (credentials) { throwNotImplemented('init') } + + /* **************************** STATE STORE OPERATORS ***************************** */ + + /** + * Retrieves the state value for given key. + * If the key doesn't exist returns undefined. + * + * @param {string} key state key identifier + * @returns {Promise} get response holding value and additional info + * @memberof StateStore + */ + async get (key) { + validateKey(key, { key }) + logger.debug(`get '${key}'`) + return this._get(key) + } + + /** + * Creates or updates a state key-value pair + * + * @param {string} key state key identifier + * @param {any} value state value + * @param {StateStorePutOptions} [options={}] put options + * @returns {Promise} key + * @memberof StateStore + */ + async put (key, value, options = {}) { + const details = { key, value, options } + validateKey(key, details) + validateValue(value, details) + validateInput(options, joi.object().label('options').keys({ ttl: joi.number() }).options({ convert: false }), details) + + const ttl = options.ttl || StateStore.DefaultTTL // => undefined, null, 0 sets to default + logger.debug(`put '${key}' with ttl ${ttl}`) + return this._put(key, value, { ttl }) + } + + /** + * Deletes a state key-value pair + * + * @param {string} key state key identifier + * @returns {Promise} key of deleted state or `null` if state does not exists + * @memberof StateStore + */ + async delete (key) { + validateKey(key, { key }) + logger.debug(`delete '${key}'`) + return this._delete(key) + } + + /* **************************** PRIVATE METHODS TO IMPLEMENT ***************************** */ + /** + * @param {string} key state key identifier + * @returns {Promise} get response holding value and additional info + * @protected + */ + async _get (key) { throwNotImplemented('_get') } + /** + * @param {string} key state key identifier + * @param {any} value state value + * @param {object} options state put options + * @returns {Promise} key + * @protected + */ + async _put (key, value, options) { throwNotImplemented('_put') } + /** + * @param {string} key state key identifier + * @returns {Promise} key of deleted state or `null` if state does not exists + * @protected + */ + async _delete (key) { throwNotImplemented('_delete') } +} + +StateStore.DefaultTTL = 86400 // 24hours + +module.exports = { StateStore } diff --git a/lib/StateError.js b/lib/StateStoreError.js similarity index 59% rename from lib/StateError.js rename to lib/StateStoreError.js index 3d98632..6483ec8 100644 --- a/lib/StateError.js +++ b/lib/StateStoreError.js @@ -1,5 +1,5 @@ /* -Copyright 2024 Adobe. All rights reserved. +Copyright 2019 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 @@ -14,7 +14,7 @@ const { ErrorWrapper, createUpdater } = require('@adobe/aio-lib-core-errors').Ai const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) /** - * @typedef {object} AdobeStateLibError + * @typedef {object} StateLibError * @property {string} message The message for the Error * @property {string} code The code for the Error * @property {string} sdk The SDK associated with the Error @@ -22,22 +22,21 @@ const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { */ /** - * Adobe State lib custom errors. + * State lib custom errors. * `e.sdkDetails` provides additional context for each error (e.g. function parameter) * * @typedef StateLibErrors * @type {object} - * @property {AdobeStateLibError} ERROR_BAD_ARGUMENT this error is thrown when an argument is missing, has invalid type, or includes invalid characters. - * @property {AdobeStateLibError} ERROR_BAD_REQUEST this error is thrown when an argument has an illegal value. - * @property {AdobeStateLibError} ERROR_NOT_IMPLEMENTED this error is thrown when a method is not implemented or when calling + * @property {StateLibError} ERROR_BAD_ARGUMENT this error is thrown when an argument is missing, has invalid type, or includes invalid characters. + * @property {StateLibError} ERROR_BAD_REQUEST this error is thrown when an argument has an illegal value. + * @property {StateLibError} ERROR_NOT_IMPLEMENTED this error is thrown when a method is not implemented or when calling * methods directly on the abstract class (StateStore). - * @property {AdobeStateLibError} ERROR_PAYLOAD_TOO_LARGE this error is thrown when the state key, state value or underlying request payload size + * @property {StateLibError} ERROR_PAYLOAD_TOO_LARGE this error is thrown when the state key, state value or underlying request payload size * exceeds the specified limitations. - * @property {AdobeStateLibError} ERROR_BAD_CREDENTIALS this error is thrown when the supplied init credentials are invalid. - * @property {AdobeStateLibError} ERROR_UNAUTHORIZED this error is thrown when the credentials are unauthorized to access the resource - * @property {AdobeStateLibError} ERROR_INTERNAL this error is thrown when an unknown error is thrown by the underlying + * @property {StateLibError} ERROR_BAD_CREDENTIALS this error is thrown when the supplied init credentials are invalid. + * @property {StateLibError} ERROR_INTERNAL this error is thrown when an unknown error is thrown by the underlying * DB provider or TVM server for credential exchange. More details can be found in `e.sdkDetails._internal`. - * @property {AdobeStateLibError} ERROR_REQUEST_RATE_TOO_HIGH this error is thrown when the request rate for accessing state is too high. + * @property {StateLibError} ERROR_REQUEST_RATE_TOO_HIGH this error is thrown when the request rate for accessing state is too high. */ const codes = {} @@ -49,24 +48,29 @@ const Updater = createUpdater( ) const E = ErrorWrapper( - 'AdobeStateLibError', - 'AdobeStateLib', + 'StateLibError', + 'StateLib', Updater ) E('ERROR_INTERNAL', '%s') E('ERROR_BAD_REQUEST', '%s') E('ERROR_BAD_ARGUMENT', '%s') -E('ERROR_UNKNOWN_PROVIDER', '%s') E('ERROR_NOT_IMPLEMENTED', 'method `%s` not implemented') -E('ERROR_UNAUTHORIZED', 'you are not authorized to access %s') E('ERROR_BAD_CREDENTIALS', 'cannot access %s, make sure your credentials are valid') E('ERROR_PAYLOAD_TOO_LARGE', 'key, value or request payload is too large') E('ERROR_REQUEST_RATE_TOO_HIGH', 'Request rate too high. Please retry after sometime.') +// this error is specific to Adobe's owned database E('ERROR_FIREWALL', 'cannot access %s because your IP is blocked by a firewall, please make sure to run in an Adobe I/O Runtime action') // eslint-disable-next-line jsdoc/require-jsdoc function logAndThrow (e) { + const internalError = e.sdkDetails._internal + // by default stringifying an Error returns '{}' because toJSON is not defined, so here we make sure that we properly + // stringify the _internal error objects + if (internalError instanceof Error && !internalError.toJSON) { + internalError.toJSON = () => Object.getOwnPropertyNames(internalError).reduce((obj, prop) => { obj[prop] = internalError[prop]; return obj }, {}) + } logger.error(JSON.stringify(e, null, 2)) throw e } diff --git a/lib/constants.js b/lib/constants.js deleted file mode 100644 index 1d10da3..0000000 --- a/lib/constants.js +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2024 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 { PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') - -// gets these values if the keys are set in the environment, if not it will use the defaults set -const { - ADOBE_STATE_STORE_ENDPOINT_PROD = 'https://storage-state-amer.app-builder.adp.adobe.io', - ADOBE_STATE_STORE_ENDPOINT_STAGE = 'http://storage-state-amer.stg.app-builder.corp.adp.adobe.io' -} = process.env - -const ADOBE_STATE_STORE_ENDPOINT = { - [PROD_ENV]: ADOBE_STATE_STORE_ENDPOINT_PROD, - [STAGE_ENV]: ADOBE_STATE_STORE_ENDPOINT_STAGE -} - -const MAX_KEY_SIZE = 1024 * 1 // 1KB -const MAX_TTL_SECONDS = 60 * 60 * 24 * 365 // 365 days - -const REGEX_PATTERN_STORE_NAMESPACE = '^(development-)?([0-9]{3,10})-([a-z0-9]{1,20})(-([a-z0-9]{1,20}))?$' -// The regex for keys, allowed chars are alphanumerical with _ and - -const REGEX_PATTERN_STORE_KEY = `^[a-zA-Z0-9-_-]{1,${MAX_KEY_SIZE}}$` - -module.exports = { - MAX_KEY_SIZE, - MAX_TTL_SECONDS, - REGEX_PATTERN_STORE_NAMESPACE, - REGEX_PATTERN_STORE_KEY, - ADOBE_STATE_STORE_ENDPOINT -} diff --git a/lib/impl/CosmosStateStore.js b/lib/impl/CosmosStateStore.js new file mode 100644 index 0000000..a340330 --- /dev/null +++ b/lib/impl/CosmosStateStore.js @@ -0,0 +1,185 @@ +/* +Copyright 2019 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 joi = require('joi') +const cosmos = require('@azure/cosmos') +const cloneDeep = require('lodash.clonedeep') +const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) + +const utils = require('../utils') +const { codes, logAndThrow } = require('../StateStoreError') +const { StateStore } = require('../StateStore') + +// eslint-disable-next-line jsdoc/require-jsdoc +async function _wrap (promise, params) { + let response + try { + response = await promise + } catch (e) { + const copyParams = cloneDeep(params) + // error handling + const status = e.statusCode || e.code + if (status === 404) { + return null + } + logger.debug(`got internal error with status ${status}: ${e.message} `) + if (status === 403) { + if (e.message.includes('blocked by your Cosmos DB account firewall settings')) { + logAndThrow(new codes.ERROR_FIREWALL({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) + } + logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['underlying DB provider'], sdkDetails: copyParams })) + } + if (status === 413) { + logAndThrow(new codes.ERROR_PAYLOAD_TOO_LARGE({ sdkDetails: copyParams })) + } + if (e.message.toLowerCase().includes('illegal')) { + // e.message is is not as descriptive or consistent. + const invalidChars = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' " + logAndThrow(new codes.ERROR_BAD_REQUEST({ messageValues: [invalidChars], sdkDetails: copyParams })) + } + if (status === 429) { + logAndThrow(new codes.ERROR_REQUEST_RATE_TOO_HIGH({ sdkDetails: copyParams })) + } + logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unknown error response from provider with status: ${status || 'unknown'}`], sdkDetails: { ...copyParams, _internal: e } })) + } + // 404 does not throw in cosmos SDK which is fine as we treat 404 as a non-error, + // here we just make sure there are no other cases of bad status codes that don't throw + const status = response.statusCode + if (status && status >= 300 && status !== 404) { + logAndThrow(new codes.ERROR_INTERNAL({ messageValues: [`unexpected response from provider with status: ${status}`], sdkDetails: { ...cloneDeep(params), _internal: response } })) + } + return response +} + +/** + * @class CosmosStateStore + * @classdesc Azure Cosmos state store implementation + * @augments StateStore + * @hideconstructor + * @private + */ +class CosmosStateStore extends StateStore { + /** + * @memberof CosmosStateStore + * @override + * @private + */ + constructor (container, partitionKey, /* istanbul ignore next */ options = { expiration: null }) { + super() + /** @private */ + this._cosmos = {} + this._cosmos.container = container + this._cosmos.partitionKey = partitionKey + this.expiration = options.expiration + } + + /** + * @param {object} credentials azure cosmos credentials + * @memberof CosmosStateStore + * @override + * @private + */ + static async init (credentials) { + const cloned = utils.withHiddenFields(credentials, ['masterKey', 'resourceToken']) + logger.debug(`init CosmosStateStore with ${JSON.stringify(cloned, null, 2)}`) + + const validation = joi.object().label('cosmos').keys({ + // either + resourceToken: joi.string(), + // or + masterKey: joi.string(), + // for both + endpoint: joi.string().required(), + databaseId: joi.string().required(), + containerId: joi.string().required(), + partitionKey: joi.string().required(), + + expiration: joi.string() // allowed for tvm response, in ISO format + }).xor('masterKey', 'resourceToken').required() + .validate(credentials) + if (validation.error) { + logAndThrow(new codes.ERROR_BAD_ARGUMENT({ + messageValues: [validation.error.message], + sdkDetails: cloned + })) + } + + const inMemoryInstance = CosmosStateStore.inMemoryInstance[credentials.partitionKey] + if (inMemoryInstance && inMemoryInstance.expiration !== credentials.expiration) { + // the TVM credentials have changed, aio-lib-core-tvm has generated new one likely because of expiration. + delete CosmosStateStore.inMemoryInstance[credentials.partitionKey] + } + + if (!CosmosStateStore.inMemoryInstance[credentials.partitionKey]) { + let cosmosClient + if (credentials.resourceToken) { + // Note: resourceToken doesn't necessarily mean that the TVM provided the credentials, it can have been provided by a user. + logger.debug('using azure cosmos resource token') + cosmosClient = new cosmos.CosmosClient({ endpoint: credentials.endpoint, consistencyLevel: 'Session', tokenProvider: /* istanbul ignore next */ async () => credentials.resourceToken }) + } else { + logger.debug('using azure cosmos master key') + cosmosClient = new cosmos.CosmosClient({ endpoint: credentials.endpoint, consistencyLevel: 'Session', key: credentials.masterKey }) + // create if not exist creates 2 additional round trips on init -> should be enabled as an option + // const { database } = await cosmosClient.databases.createIfNotExists({ id: credentials.databaseId }) + // container = (await database.containers.createIfNotExists({ id: credentials.containerId })).container + } + const container = cosmosClient.database(credentials.databaseId).container(credentials.containerId) + CosmosStateStore.inMemoryInstance[credentials.partitionKey] = new CosmosStateStore(container, credentials.partitionKey, { expiration: credentials.expiration }) + } else { + logger.debug('reusing exising in-memory CosmosClient initialization') + } + return CosmosStateStore.inMemoryInstance[credentials.partitionKey] + } + + /** + * @memberof CosmosStateStore + * @override + * @private + */ + async _get (key) { + const response = await _wrap(this._cosmos.container.item(key, this._cosmos.partitionKey).read(), { key }) + // if 404 response.resource = undefined + if (!response.resource) return undefined + if (response.resource.ttl < 0) { + return { value: response.resource.value, expiration: null } + } + + // azure ts and ttl in seconds, date takes ms + const expiration = new Date(response.resource._ts * 1000 + response.resource.ttl * 1000).toISOString() + return response.resource && { value: response.resource.value, expiration } + } + + /** + * @memberof CosmosStateStore + * @override + * @private + */ + async _put (key, value, options) { + const ttl = options.ttl < 0 ? -1 : options.ttl + await _wrap(this._cosmos.container.items.upsert({ id: key, partitionKey: this._cosmos.partitionKey, ttl, value }), { key, value, options }) + return key + } + + /** + * @memberof CosmosStateStore + * @override + * @private + */ + async _delete (key) { + // if throws 404 wrap returns null + const ret = await _wrap(this._cosmos.container.item(key, this._cosmos.partitionKey).delete(), { key }) + return ret && key + } +} + +CosmosStateStore.inMemoryInstance = {} +module.exports = { CosmosStateStore } diff --git a/lib/init.js b/lib/init.js index 47afef0..7236fff 100644 --- a/lib/init.js +++ b/lib/init.js @@ -1,5 +1,5 @@ /* -Copyright 2024 Adobe. All rights reserved. +Copyright 2019 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 @@ -10,10 +10,13 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +const TvmClient = require('@adobe/aio-lib-core-tvm') const logger = require('@adobe/aio-lib-core-logging')('@adobe/aio-lib-state', { provider: 'debug' }) const utils = require('./utils') -const { AdobeState } = require('./AdobeState') +const { CosmosStateStore } = require('./impl/CosmosStateStore') +const { StateStore } = require('./StateStore') +const { codes, logAndThrow } = require('./StateStoreError') /* *********************************** typedefs *********************************** */ /** @@ -25,8 +28,41 @@ const { AdobeState } = require('./AdobeState') * @property {string} auth auth key */ +/** + * An object holding the Azure Cosmos resource credentials with permissions on a single partition and container + * + * @typedef AzureCosmosPartitionResourceCredentials + * @type {object} + * @property {string} endpoint cosmosdb resource endpoint + * @property {string} resourceToken cosmosdb resource token restricted to the partitionKey + * @property {string} databaseId id for cosmosdb database + * @property {string} containerId id for cosmosdb container within database + * @property {string} partitionKey key for cosmosdb partition within container authorized by resource token + */ + +/** + * An object holding the Azure Cosmos account master key + * + * @typedef AzureCosmosMasterCredentials + * @type {object} + * @property {string} endpoint cosmosdb resource endpoint + * @property {string} masterKey cosmosdb account masterKey + * @property {string} databaseId id for cosmosdb database + * @property {string} containerId id for cosmosdb container within database + * @property {string} partitionKey key for cosmosdb partition where data will be stored + */ /* *********************************** helpers & init() *********************************** */ +// eslint-disable-next-line jsdoc/require-jsdoc +async function wrapTVMRequest (promise, params) { + return promise + .catch(e => { + if (e.sdkDetails.status === 401 || e.sdkDetails.status === 403) { + logAndThrow(new codes.ERROR_BAD_CREDENTIALS({ messageValues: ['TVM'], sdkDetails: e.sdkDetails })) + } + throw e // throw raw tvm error + }) +} /** * Initializes and returns the key-value-store SDK. * @@ -43,15 +79,45 @@ const { AdobeState } = require('./AdobeState') * to use ootb credentials to access the state management service. OpenWhisk * namespace and auth can also be passed through environment variables: * `__OW_NAMESPACE` and `__OW_API_KEY` - * @returns {Promise} An AdobeStateStore instance + * @param {AzureCosmosMasterCredentials|AzureCosmosPartitionResourceCredentials} [config.cosmos] + * [Azure Cosmos resource credentials]{@link AzureCosmosPartitionResourceCredentials} or + * [Azure Cosmos account credentials]{@link AzureCosmosMasterCredentials} + * @param {object} [config.tvm] tvm configuration, applies only when passing OpenWhisk credentials + * @param {string} [config.tvm.apiUrl] alternative tvm api url. + * @param {string} [config.tvm.cacheFile] alternative tvm cache file, set to `false` to disable caching of temporary credentials. + * @returns {Promise} A StateStore instance */ async function init (config = {}) { - const logConfig = utils.withHiddenFields(config, ['ow.auth']) + // 0. log + const logConfig = utils.withHiddenFields(config, ['ow.auth', 'cosmos.resourceToken', 'cosmos.masterKey']) logger.debug(`init with config: ${JSON.stringify(logConfig, null, 2)}`) - const { auth: apikey, namespace } = (config.ow ?? {}) - return AdobeState.init({ apikey, namespace }) + // 1. set provider + const provider = 'cosmos' // only cosmos is supported for now + + // 2. instantiate tvm if ow credentials + let tvm + if (provider === 'cosmos' && !config.cosmos) { + // remember config.ow can be empty if env vars are set + const tvmArgs = { ow: config.ow, ...config.tvm } + tvm = await TvmClient.init(tvmArgs) + } + + // 3. return state store based on provider + switch (provider) { + case 'cosmos': + if (config.cosmos) { + logger.debug('init with user provided cosmosDB credentials') + // Do not reuse cosmos client instances for BringYourOwn creds + CosmosStateStore.inMemoryInstance = {} + return CosmosStateStore.init(config.cosmos) + } + logger.debug('init with openwhisk credentials') + return CosmosStateStore.init(await wrapTVMRequest(tvm.getAzureCosmosCredentials())) + // default: + // throw new StateStoreError(`provider '${provider}' is not supported.`, StateStoreError.codes.BadArgument) + } } module.exports = { init } diff --git a/lib/utils.js b/lib/utils.js index aeabd8d..60ababc 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,42 +1,16 @@ -/* -Copyright 2024 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 cloneDeep = require('lodash.clonedeep') -/** - * Replaces any hidden field values with the string '' - * - * @param {object} sourceObj the object to needs fields hidden - * @param {Array} fieldsToHide the fields that need the value hidden - * @returns {object} the source object but with the specified fields hidden - */ -function withHiddenFields (sourceObj, fieldsToHide) { - if (!sourceObj || !Array.isArray(fieldsToHide)) { - return sourceObj - } - - const copyConfig = cloneDeep(sourceObj) - fieldsToHide.forEach(f => { +// eslint-disable-next-line jsdoc/require-jsdoc +function withHiddenFields (toCopy, fields) { + if (!toCopy) return toCopy + const copyConfig = cloneDeep(toCopy) + fields.forEach(f => { const keys = f.split('.') const lastKey = keys.slice(-1)[0] - // keep last key - const traverse = keys - .slice(0, -1) - .reduce((obj, k) => obj && obj[k], copyConfig) + const traverse = keys.slice(0, -1).reduce((obj, k) => obj && obj[k], copyConfig) - if (traverse && traverse[lastKey]) { - traverse[lastKey] = '' - } + if (traverse && traverse[lastKey]) traverse[lastKey] = '' }) return copyConfig } diff --git a/package.json b/package.json index e456070..b70e828 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,9 @@ "key-value" ], "devDependencies": { + "@types/hapi__joi": "^17.1.9", "@types/jest": "^29.5.0", "codecov": "^3.6.1", - "dotenv": "^16.3.1", "eslint": "^8", "eslint-config-standard": "^17", "eslint-plugin-import": "^2", @@ -53,9 +53,9 @@ "dependencies": { "@adobe/aio-lib-core-errors": "^3.1.0", "@adobe/aio-lib-core-logging": "^2.0.0", - "@adobe/aio-lib-core-networking": "^4.1.0", - "@adobe/aio-lib-env": "^2.0.0", - "ajv": "^8.12.0", + "@adobe/aio-lib-core-tvm": "^3.0.0", + "@azure/cosmos": "^3.17.1", + "joi": "^17.4.2", "lodash.clonedeep": "^4.5.0" } } diff --git a/test/AdobeState.test.js b/test/AdobeState.test.js deleted file mode 100644 index 6fd7c4d..0000000 --- a/test/AdobeState.test.js +++ /dev/null @@ -1,382 +0,0 @@ -/* -Copyright 2024 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. -*/ - -// @ts-nocheck -const { getCliEnv, DEFAULT_ENV, PROD_ENV, STAGE_ENV } = require('@adobe/aio-lib-env') -const { HttpExponentialBackoff } = require('@adobe/aio-lib-core-networking') -const { AdobeState } = require('../lib/AdobeState') -const querystring = require('node:querystring') -const { Buffer } = require('node:buffer') - -// constants ////////////////////////////////////////////////////////// - -const mockExponentialBackoff = jest.fn() -HttpExponentialBackoff.mockImplementation(() => { - return { - exponentialBackoff: mockExponentialBackoff - } -}) - -const fakeCredentials = { - apikey: 'some-api-key', - namespace: 'some-namespace' -} - -const myConstants = { - ADOBE_STATE_STORE_ENDPOINT: { - prod: 'https://prod', - stage: 'https://stage' - } -} - -// helpers ////////////////////////////////////////////////////////// - -const wrapInFetchResponse = (body) => { - return { - ok: true, - headers: { - get: () => 'fake req id' - }, - json: async () => body - } -} - -const wrapInFetchError = (status) => { - return { - ok: false, - headers: { - get: () => 'fake req id' - }, - json: async () => 'error', - text: async () => 'error', - status - } -} - -// mocks ////////////////////////////////////////////////////////// - -jest.mock('@adobe/aio-lib-core-networking') - -jest.mock('../lib/constants', () => { - return { - ...jest.requireActual('../lib/constants'), - ...myConstants - } -}) - -jest.mock('@adobe/aio-lib-env', () => { - return { - ...jest.requireActual('@adobe/aio-lib-env'), - getCliEnv: jest.fn() - } -}) - -// jest globals ////////////////////////////////////////////////////////// - -beforeEach(() => { - getCliEnv.mockReturnValue(DEFAULT_ENV) - mockExponentialBackoff.mockReset() -}) - -// ////////////////////////////////////////////////////////// - -describe('init and constructor', () => { - test('good credentials', async () => { - const credentials = { - apikey: 'some-api-key', - namespace: 'some-namespace' - } - - const store = await AdobeState.init(credentials) - expect(store.apikey).toEqual(credentials.apikey) - expect(store.namespace).toEqual(credentials.namespace) - expect(store.endpoint).toBeDefined() - }) - - test('bad credentials (no apikey and no namespace)', async () => { - await expect(AdobeState.init()).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') - }) - - test('bad credentials (no apikey)', async () => { - const credentials = { - namespace: 'some-namespace' - } - - await expect(AdobeState.init(credentials)).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') - }) - - test('bad credentials (no namespace)', async () => { - const credentials = { - apikey: 'some-apikey' - } - - await expect(AdobeState.init(credentials)).rejects - .toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] apikey and/or namespace is missing') - }) -}) - -describe('get', () => { - let store - - beforeEach(async () => { - store = await AdobeState.init(fakeCredentials) - }) - - test('success', async () => { - const key = 'valid-key' - const fetchResponseJson = { - expiration: 999, - value: 'foo' - } - - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const value = await store.get(key) - expect(value).toEqual(fetchResponseJson) - }) - - test('invalid key', async () => { - const key = 'bad/key' - - await expect(store.get(key)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key') - }) - - test('not found', async () => { - const key = 'not-found-key' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) - - const value = await store.get(key) - expect(value).toEqual(undefined) - }) -}) - -describe('put', () => { - let store - - beforeEach(async () => { - store = await AdobeState.init(fakeCredentials) - }) - - test('success (string value) no ttl', async () => { - const key = 'valid-key' - const value = 'some-value' - const fetchResponseJson = {} - - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const returnKey = await store.put(key, value) - expect(returnKey).toEqual(key) - }) - - test('success (string value) with ttl', async () => { - const key = 'valid-key' - const value = 'some-value' - const fetchResponseJson = {} - - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const returnKey = await store.put(key, value, { ttl: 999 }) - expect(returnKey).toEqual(key) - }) - - test('failure (invalid key)', async () => { - const key = 'invalid/key' - const value = 'some-value' - - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value') - }) - - test('failure (binary value)', async () => { - const key = 'valid-key' - const value = Buffer.from([0x61, 0x72, 0x65, 0x26, 0x35, 0x55, 0xff]) - - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_ARGUMENT] invalid key and/or value') - }) - - test('coverage: 401 error', async () => { - const key = 'some-key' - const value = 'some-value' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(401)) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_UNAUTHORIZED] you are not authorized to access underlying DB provider') - }) - - test('coverage: 403 error', async () => { - const key = 'some-key' - const value = 'some-value' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(403)) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_BAD_CREDENTIALS] cannot access underlying DB provider, make sure your credentials are valid') - }) - - test('coverage: 413 error', async () => { - const key = 'some-key' - const value = 'some-value' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(413)) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_PAYLOAD_TOO_LARGE] key, value or request payload is too large underlying DB provider') - }) - - test('coverage: 429 error', async () => { - const key = 'some-key' - const value = 'some-value' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(429)) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_REQUEST_RATE_TOO_HIGH] Request rate too high. Please retry after sometime.') - }) - - test('coverage: unknown server error', async () => { - const key = 'some-key' - const value = 'some-value' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(500)) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_INTERNAL] unexpected response from provider with status: 500') - }) - - test('coverage: unknown error (fetch network failure)', async () => { - const key = 'some-key' - const value = 'some-value' - - const error = new Error('some network error') - error.code = 502 - mockExponentialBackoff.mockRejectedValue(error) - await expect(store.put(key, value)).rejects.toThrow('[AdobeStateLib:ERROR_INTERNAL] unexpected response from provider with status: 502') - }) -}) - -describe('delete', () => { - let store - - beforeEach(async () => { - store = await AdobeState.init(fakeCredentials) - }) - - test('success', async () => { - const key = 'valid-key' - const fetchResponseJson = {} - - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const returnKey = await store.delete(key) - expect(returnKey).toEqual(key) - }) - - test('not found', async () => { - const key = 'not-found-key' - - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) - - const value = await store.delete(key) - expect(value).toEqual(null) - }) -}) - -describe('deleteAll', () => { - let store - - beforeEach(async () => { - store = await AdobeState.init(fakeCredentials) - }) - - test('success', async () => { - const fetchResponseJson = {} - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const value = await store.deleteAll() - expect(value).toEqual(true) - }) - - test('not found', async () => { - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) - - const value = await store.deleteAll() - expect(value).toEqual(false) - }) -}) - -describe('any', () => { - let store - - beforeEach(async () => { - store = await AdobeState.init(fakeCredentials) - }) - - test('success', async () => { - const fetchResponseJson = {} - mockExponentialBackoff.mockResolvedValue(wrapInFetchResponse(fetchResponseJson)) - - const value = await store.any() - expect(value).toEqual(true) - }) - - test('not found', async () => { - mockExponentialBackoff.mockResolvedValue(wrapInFetchError(404)) - - const value = await store.any() - expect(value).toEqual(false) - }) -}) - -describe('private methods', () => { - test('getAuthorizationHeaders (private)', async () => { - const expectedHeaders = { - Authorization: `Basic ${fakeCredentials.apikey}` - } - const store = await AdobeState.init(fakeCredentials) - - expect(store.getAuthorizationHeaders()).toEqual(expectedHeaders) - }) - - describe('createRequestUrl (private)', () => { - test('no params', async () => { - const env = PROD_ENV - getCliEnv.mockReturnValue(env) - - // need to instantiate a new store, when env changes - const store = await AdobeState.init(fakeCredentials) - - const url = store.createRequestUrl() - expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}`) - }) - - test('key set, no query params', async () => { - const key = 'some-key' - const env = STAGE_ENV - getCliEnv.mockReturnValue(env) - - // need to instantiate a new store, when env changes - const store = await AdobeState.init(fakeCredentials) - - const url = store.createRequestUrl(key) - expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}`) - }) - - test('key set, some query params', async () => { - const queryParams = { - foo: 'bar', - cat: 'bat' - } - const key = 'some-key' - const env = STAGE_ENV - getCliEnv.mockReturnValue(env) - - // need to instantiate a new store, when env changes - const store = await AdobeState.init(fakeCredentials) - - const url = store.createRequestUrl(key, queryParams) - expect(url).toEqual(`${myConstants.ADOBE_STATE_STORE_ENDPOINT[env]}/v1/containers/${fakeCredentials.namespace}/data/${key}?${querystring.stringify(queryParams)}`) - }) - }) -}) diff --git a/test/StateStore.test.js b/test/StateStore.test.js new file mode 100644 index 0000000..08fce17 --- /dev/null +++ b/test/StateStore.test.js @@ -0,0 +1,153 @@ +/* +Copyright 2019 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. +*/ + +// @ts-nocheck +const { StateStore } = require('../lib/StateStore') + +describe('init', () => { + // eslint-disable-next-line jest/expect-expect + test('missing implementation', async () => { + expect.hasAssertions() + await global.expectToThrowNotImplemented(StateStore.init.bind(StateStore), 'init') + }) +}) + +describe('constructor', () => { + // eslint-disable-next-line jest/expect-expect + test('missing implementation', async () => { + expect.hasAssertions() + await global.expectToThrowNotImplemented(() => new StateStore(false), 'StateStore') + }) +}) + +describe('get', () => { + // eslint-disable-next-line jest/expect-expect + test('missing implementation', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowNotImplemented(state.get.bind(state, 'key'), '_get') + }) + // eslint-disable-next-line jest/expect-expect + test('bad key type', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.get.bind(state, 123), ['string', 'key'], { key: 123 }) + }) + // eslint-disable-next-line jest/expect-expect + test('bad key characters', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) + }) + test('calls _get (part of interface)', async () => { + expect.hasAssertions() + const state = new StateStore(true) + state._get = jest.fn() + await state.get('key') + expect(state._get).toHaveBeenCalledTimes(1) + expect(state._get).toHaveBeenCalledWith('key') + expect(global.mockLogDebug).toHaveBeenCalledWith('get \'key\'') + }) +}) + +describe('put', () => { + // eslint-disable-next-line jest/expect-expect + test('missing implementation', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowNotImplemented(state.put.bind(state, 'key', 'value'), '_put') + }) + // eslint-disable-next-line jest/expect-expect + test('bad key type', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.put.bind(state, 123, 'value', {}), ['string', 'key'], { key: 123, value: 'value', options: {} }) + }) + // eslint-disable-next-line jest/expect-expect + test('bad key characters', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) + }) + // eslint-disable-next-line jest/expect-expect + test('bad options', async () => { + expect.hasAssertions() + const state = new StateStore(true) + const expectedDetails = { key: 'key', value: 'value' } + await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', 'options'), ['object', 'options'], { ...expectedDetails, options: 'options' }) + await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { nonexiting__option: 'value' }), ['nonexiting__option', 'not allowed'], { ...expectedDetails, options: { nonexiting__option: 'value' } }) + await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { ttl: 'value' }), ['ttl', 'number'], { ...expectedDetails, options: { ttl: 'value' } }) + await global.expectToThrowBadArg(state.put.bind(state, 'key', 'value', { ttl: '1' }), ['ttl', 'number'], { ...expectedDetails, options: { ttl: '1' } }) + }) + test('calls _put with default ttl when options is undefined or options.ttl is = 0', async () => { + expect.hasAssertions() + const state = new StateStore(true) + state._put = jest.fn() + await state.put('key', 'value') + expect(state._put).toHaveBeenCalledTimes(1) + expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: StateStore.DefaultTTL }) + + state._put.mockReset() + await state.put('key', 'value', { ttl: 0 }) + expect(state._put).toHaveBeenCalledTimes(1) + expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: StateStore.DefaultTTL }) + expect(global.mockLogDebug).toHaveBeenCalledWith(`put 'key' with ttl ${StateStore.DefaultTTL}`) + }) + test('calls _put with custom ttl when options.ttl is set', async () => { + expect.hasAssertions() + const state = new StateStore(true) + state._put = jest.fn() + await state.put('key', 'value', { ttl: 99 }) + expect(state._put).toHaveBeenCalledTimes(1) + expect(state._put).toHaveBeenCalledWith('key', 'value', { ttl: 99 }) + expect(global.mockLogDebug).toHaveBeenCalledWith('put \'key\' with ttl 99') + }) +}) + +describe('delete', () => { + // eslint-disable-next-line jest/expect-expect + test('missing implementation', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowNotImplemented(state.delete.bind(state, 'key'), '_delete') + }) + // eslint-disable-next-line jest/expect-expect + test('bad key type', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.delete.bind(state, 123), ['string', 'key'], { key: 123 }) + }) + // eslint-disable-next-line jest/expect-expect + test('bad key characters', async () => { + expect.hasAssertions() + const state = new StateStore(true) + await global.expectToThrowBadArg(state.put.bind(state, '?test', 'value', {}), ['Key', 'cannot', 'contain'], { key: '?test', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't#est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't#est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 't\\est', 'value', {}), ['Key', 'cannot', 'contain'], { key: 't\\est', value: 'value', options: {} }) + await global.expectToThrowBadArg(state.put.bind(state, 'test/', 'value', {}), ['Key', 'cannot', 'contain'], { key: 'test/', value: 'value', options: {} }) + }) + test('calls _delete (part of interface)', async () => { + expect.hasAssertions() + const state = new StateStore(true) + state._delete = jest.fn() + await state.delete('key', 'value') + expect(state._delete).toHaveBeenCalledTimes(1) + expect(state._delete).toHaveBeenCalledWith('key') + expect(global.mockLogDebug).toHaveBeenCalledWith('delete \'key\'') + }) +}) diff --git a/test/impl/CosmosStateStore.test.js b/test/impl/CosmosStateStore.test.js new file mode 100644 index 0000000..75d8476 --- /dev/null +++ b/test/impl/CosmosStateStore.test.js @@ -0,0 +1,361 @@ +/* +Copyright 2019 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 { CosmosStateStore } = require('../../lib/impl/CosmosStateStore') +const { StateStore } = require('../../lib/StateStore') +const cloneDeep = require('lodash.clonedeep') + +const cosmos = require('@azure/cosmos') +jest.mock('@azure/cosmos') + +const stateLib = require('../../index') + +const fakeCosmosResourceCredentials = { + endpoint: 'https://fake.com', + resourceToken: 'fakeToken', + databaseId: 'fakedb', + containerId: 'fakeContainer', + partitionKey: 'fakePK' +} + +const fakeCosmosMasterCredentials = { + endpoint: 'https://fake.com', + masterKey: 'fakeKey', + databaseId: 'fakedb', + containerId: 'fakeContainer', + partitionKey: 'fakePK' +} + +const fakeCosmosTVMResponse = { + expiration: new Date(8640000000000000).toISOString(), + ...fakeCosmosResourceCredentials +} + +const cosmosDatabaseMock = jest.fn() +const cosmosContainerMock = jest.fn() +beforeEach(async () => { + CosmosStateStore.inMemoryInstance = {} + cosmos.CosmosClient.mockReset() + cosmosContainerMock.mockReset() + cosmosDatabaseMock.mockReset() + + cosmos.CosmosClient.mockImplementation(() => { + return { + database: cosmosDatabaseMock.mockReturnValue({ + container: cosmosContainerMock + }) + } + }) +}) + +// eslint-disable-next-line jsdoc/require-jsdoc +async function testProviderErrorHandling (func, mock, fparams) { + const illegalId = "The following characters are restricted and cannot be used in the Id property: '/', '\\', '?', '#' " + // eslint-disable-next-line jsdoc/require-jsdoc + async function testOne (status, errorMessage, expectCheck, isInternal, ...addArgs) { + const providerError = new Error(errorMessage) + if (status) { + providerError.code = status + } + + const expectedErrorDetails = { ...fparams } + if (isInternal) { expectedErrorDetails._internal = providerError } + mock.mockReset() + mock.mockRejectedValue(providerError) + await global[expectCheck](func, ...addArgs, expectedErrorDetails) + } + + await testOne(403, 'This is blocked by your Cosmos DB account firewall settings.', 'expectToThrowFirewall') + await testOne(403, 'fakeError', 'expectToThrowForbidden') + await testOne(413, 'fakeError', 'expectToThrowTooLarge') + await testOne(500, 'fakeError', 'expectToThrowInternalWithStatus', true, 500) + await testOne(undefined, 'fakeError', 'expectToThrowInternal', true) + await testOne(undefined, 'contains illegal chars', 'expectToThrowBadRequest', false, [illegalId]) + await testOne(429, 'fakeError', 'expectToThrowRequestRateTooHigh') + // when provider resolves with bad status which is not 404 + const providerResponse = { + statusCode: 400 + } + mock.mockReset() + mock.mockResolvedValue(providerResponse) + await global.expectToThrowInternalWithStatus(func, 400, { ...fparams, _internal: providerResponse }) +} + +describe('init', () => { + // eslint-disable-next-line jsdoc/require-jsdoc + async function testInitBadArg (object, missing, expectedWords) { + if (typeof missing === 'string') missing = [missing] + if (typeof expectedWords === 'string') expectedWords = [expectedWords] + + if (!expectedWords) expectedWords = missing + + const args = cloneDeep(object) + + let expectedErrorDetails = {} + if (args) { + missing.forEach(m => delete args[m]) + expectedErrorDetails = cloneDeep(args) + if (expectedErrorDetails.masterKey) expectedErrorDetails.masterKey = '' + if (expectedErrorDetails.resourceToken) expectedErrorDetails.resourceToken = '' + } + + await global.expectToThrowBadArg(CosmosStateStore.init.bind(CosmosStateStore, args), expectedWords, expectedErrorDetails) + } + const checkInitDebugLogNoSecrets = (str) => expect(global.mockLogDebug).not.toHaveBeenCalledWith(expect.stringContaining(str)) + + describe('with bad args', () => { + // eslint-disable-next-line jest/expect-expect + test('with undefined credentials', async () => { + await testInitBadArg(undefined, [], ['cosmos']) + }) + // eslint-disable-next-line jest/expect-expect + test('with resourceToken and missing endpoint, databaseId, containerId, partitionKey', async () => { + const array = ['endpoint', 'databaseId', 'containerId', 'partitionKey'] + for (let i = 0; i < array.length; i++) { + await testInitBadArg(fakeCosmosResourceCredentials, array[i], 'required') + } + }) + // eslint-disable-next-line jest/expect-expect + test('with masterKey and missing endpoint, databaseId, containerId, partitionKey', async () => { + const array = ['endpoint', 'databaseId', 'containerId', 'partitionKey'] + for (let i = 0; i < array.length; i++) { + await testInitBadArg(fakeCosmosMasterCredentials, array[i], 'required') + } + }) + // eslint-disable-next-line jest/expect-expect + test('with missing masterKey and resourceToken', async () => { + await testInitBadArg(fakeCosmosMasterCredentials, ['resourceToken', 'masterKey']) + }) + // eslint-disable-next-line jest/expect-expect + test('with both masterKey and resourceToken', async () => { + const args = { ...fakeCosmosResourceCredentials, masterKey: 'fakeKey' } + await testInitBadArg(args, [], ['resourceToken', 'masterKey']) + }) + // eslint-disable-next-line jest/expect-expect + test('with unknown option', async () => { + const args = { ...fakeCosmosMasterCredentials, someFake__unknown: 'hello' } + await testInitBadArg(args, [], ['someFake__unknown', 'not', 'allowed']) + }) + describe('with correct args', () => { + const testInitOK = async (credentials) => { + const state = await CosmosStateStore.init(credentials) + expect(state).toBeInstanceOf(CosmosStateStore) + expect(state).toBeInstanceOf(StateStore) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) + expect(cosmos.CosmosClient).toHaveBeenCalledWith(expect.objectContaining({ endpoint: credentials.endpoint })) + expect(cosmosDatabaseMock).toHaveBeenCalledTimes(1) + expect(cosmosDatabaseMock).toHaveBeenCalledWith(credentials.databaseId) + expect(cosmosContainerMock).toHaveBeenCalledTimes(1) + expect(cosmosContainerMock).toHaveBeenCalledWith(credentials.containerId) + } + + // eslint-disable-next-line jest/expect-expect + test('with resourceToken', async () => { + await testInitOK(fakeCosmosResourceCredentials) + checkInitDebugLogNoSecrets(fakeCosmosResourceCredentials.resourceToken) + }) + // eslint-disable-next-line jest/expect-expect + test('with resourceToken and expiration (tvm response format)', async () => { + await testInitOK(fakeCosmosTVMResponse) + checkInitDebugLogNoSecrets(fakeCosmosTVMResponse.resourceToken) + }) + // eslint-disable-next-line jest/expect-expect + test('with masterKey', async () => { + await testInitOK(fakeCosmosMasterCredentials) + checkInitDebugLogNoSecrets(fakeCosmosMasterCredentials.masterKey) + }) + test('successive calls should reuse the CosmosStateStore instance - with resourceToken', async () => { + await testInitOK(fakeCosmosTVMResponse) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) + cosmos.CosmosClient.mockReset() + await CosmosStateStore.init(fakeCosmosTVMResponse) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(0) + }) + test('successive calls should reuse the CosmosStateStore instance - with masterkey', async () => { + // note this test may be confusing as no reuse is made with BYO credentials, but the cache is actually deleted by the top level init file. See test below. + await testInitOK(fakeCosmosMasterCredentials) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) + cosmos.CosmosClient.mockReset() + await CosmosStateStore.init(fakeCosmosMasterCredentials) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(0) + }) + test('No reuse for BYO creds', async () => { + await stateLib.init({ cosmos: fakeCosmosMasterCredentials }) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) + await stateLib.init({ cosmos: fakeCosmosMasterCredentials }) + // New CosmosClient instance generated again + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(2) + }) + test('No reuse if TVM credential expiration changed', async () => { + await testInitOK(fakeCosmosTVMResponse) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(1) + await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(0).toISOString() }) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(2) + await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(1).toISOString() }) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(3) + // double check, same credentials no additional call + await CosmosStateStore.init({ ...fakeCosmosTVMResponse, expiration: new Date(1).toISOString() }) + expect(cosmos.CosmosClient).toHaveBeenCalledTimes(3) + }) + }) + }) +}) + +describe('_get', () => { + const cosmosItemMock = jest.fn() + const cosmosItemReadMock = jest.fn() + beforeEach(async () => { + cosmosItemMock.mockReset() + cosmosItemReadMock.mockReset() + cosmosContainerMock.mockReturnValue({ + item: cosmosItemMock.mockReturnValue({ + read: cosmosItemReadMock + }) + }) + }) + + test('with existing key value and no ttl', async () => { + cosmosItemReadMock.mockResolvedValue({ + resource: { + value: 'fakeValue', + _ts: 123456789, + ttl: -1 + } + }) + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const res = await state._get('fakeKey') + expect(res.value).toEqual('fakeValue') + expect(res.expiration).toEqual(null) + expect(cosmosItemReadMock).toHaveBeenCalledTimes(1) + expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) + }) + test('with existing key and value=undefined', async () => { + cosmosItemReadMock.mockResolvedValue({ + resource: { + value: undefined, + _ts: 123456789, + ttl: -1 + } + }) + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const res = await state._get('fakeKey') + expect(res.value).toEqual(undefined) + expect(res.expiration).toEqual(null) + }) + test('with non existing key value', async () => { + cosmosItemReadMock.mockResolvedValue({ + resource: undefined, + statusCode: 404 + }) + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const res = await state._get('fakeKey') + expect(res).toEqual(undefined) + }) + test('with key value that has a non negative ttl', async () => { + cosmosItemReadMock.mockResolvedValue({ + resource: { + value: { a: { fake: 'value' } }, + _ts: 123456789, + ttl: 10 + } + }) + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const res = await state._get('fakeKey') + expect(res.value).toEqual({ a: { fake: 'value' } }) + expect(res.expiration).toEqual(new Date(123456789 * 1000 + 10 * 1000).toISOString()) + }) + // eslint-disable-next-line jest/expect-expect + test('with error response from provider', async () => { + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + await testProviderErrorHandling(state._get.bind(state, 'key'), cosmosItemReadMock, { key: 'key' }) + }) +}) + +describe('_delete', () => { + const cosmosItemMock = jest.fn() + const cosmosItemDeleteMock = jest.fn() + beforeEach(async () => { + cosmosItemMock.mockReset() + cosmosItemDeleteMock.mockReset() + cosmosContainerMock.mockReturnValue({ + item: cosmosItemMock.mockReturnValue({ + delete: cosmosItemDeleteMock.mockResolvedValue({}) + }) + }) + }) + + test('with no errors', async () => { + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const ret = await state._delete('fakeKey') + expect(ret).toEqual('fakeKey') + expect(cosmosItemDeleteMock).toHaveBeenCalledTimes(1) + expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) + }) + test('when cosmos return with a 404 (should return null)', async () => { + cosmosItemDeleteMock.mockRejectedValue({ code: 404 }) + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const ret = await state._delete('fakeKey') + expect(ret).toEqual(null) + expect(cosmosItemDeleteMock).toHaveBeenCalledTimes(1) + expect(cosmosItemMock).toHaveBeenCalledWith('fakeKey', state._cosmos.partitionKey) + }) + // eslint-disable-next-line jest/expect-expect + test('with error response from provider', async () => { + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + await testProviderErrorHandling(state._delete.bind(state, 'key'), cosmosItemDeleteMock, { key: 'key' }) + }) +}) + +describe('_put', () => { + const cosmosUpsertMock = jest.fn() + beforeEach(async () => { + cosmosUpsertMock.mockReset() + cosmosContainerMock.mockReturnValue({ + items: { + upsert: cosmosUpsertMock.mockResolvedValue({}) + } + }) + }) + + test('with default ttl (ttl is always set)', async () => { + const key = 'fakeKey' + const value = 'fakeValue' + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const ret = await state._put(key, 'fakeValue', { ttl: StateStore.DefaultTTL }) + expect(ret).toEqual(key) + expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) + expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: StateStore.DefaultTTL, value }) + }) + test('with positive ttl', async () => { + const key = 'fakeKey' + const value = 'fakeValue' + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const ret = await state._put(key, 'fakeValue', { ttl: 99 }) + expect(ret).toEqual(key) + expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) + expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: 99, value }) + }) + test('with negative ttl (converts to -1 always)', async () => { + const key = 'fakeKey' + const value = 'fakeValue' + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + const ret = await state._put(key, 'fakeValue', { ttl: -99 }) + expect(ret).toEqual(key) + expect(cosmosUpsertMock).toHaveBeenCalledTimes(1) + expect(cosmosUpsertMock).toHaveBeenCalledWith({ id: key, partitionKey: state._cosmos.partitionKey, ttl: -1, value }) + }) + // eslint-disable-next-line jest/expect-expect + test('with error response from provider', async () => { + const state = await CosmosStateStore.init(fakeCosmosResourceCredentials) + await testProviderErrorHandling(state._put.bind(state, 'key', 'value', {}), cosmosUpsertMock, { key: 'key', value: 'value', options: {} }) + }) +}) diff --git a/test/init.test.js b/test/init.test.js index 5e53b7b..e67ae48 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -1,5 +1,5 @@ /* -Copyright 2024 Adobe. All rights reserved. +Copyright 2019 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 @@ -11,39 +11,102 @@ governing permissions and limitations under the License. */ const stateLib = require('../index') -describe('init', () => { - const env = process.env +const { CosmosStateStore } = require('../lib/impl/CosmosStateStore') +jest.mock('../lib/impl/CosmosStateStore.js') - beforeEach(() => { - jest.resetModules() - process.env = { ...env } - }) +const TvmClient = require('@adobe/aio-lib-core-tvm') +jest.mock('@adobe/aio-lib-core-tvm') - afterEach(() => { - process.env = env +describe('init', () => { + /* Common setup for init tests */ + beforeEach(async () => { + CosmosStateStore.mockRestore() + CosmosStateStore.init = jest.fn() }) - const fakeOWCreds = { - auth: 'fakeAuth', - namespace: 'fakeNS' - } + const checkInitDebugLogNoSecrets = (str) => expect(global.mockLogDebug).not.toHaveBeenCalledWith(expect.stringContaining(str)) - test('pass OW creds', async () => { - expect.hasAssertions() - const store = await stateLib.init({ ow: fakeOWCreds }) + describe('when user db credentials', () => { + const fakeCosmosConfig = { - expect(store.namespace).toEqual(fakeOWCreds.namespace) - expect(store.apikey).toEqual(fakeOWCreds.auth) + masterKey: 'fakeKey', + resourceToken: 'fakeToken' + } + test('with cosmos config', async () => { + expect.hasAssertions() + await stateLib.init({ cosmos: fakeCosmosConfig }) + expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) + expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeCosmosConfig) + expect(TvmClient.init).toHaveBeenCalledTimes(0) + expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('cosmos')) + checkInitDebugLogNoSecrets(fakeCosmosConfig.masterKey) + checkInitDebugLogNoSecrets(fakeCosmosConfig.resourceToken) + }) }) - test('when empty config to be able to pass OW creds as env variables', async () => { - process.env.__OW_NAMESPACE = 'some-namespace' - process.env.__OW_API_KEY = 'some-api-key' - - expect.hasAssertions() - const store = await stateLib.init() - - expect(store.namespace).toEqual(process.env.__OW_NAMESPACE) - expect(store.apikey).toEqual(process.env.__OW_API_KEY) + describe('with openwhisk credentials', () => { + const fakeTVMResponse = { + fakeTVMResponse: 'response' + } + const fakeOWCreds = { + auth: 'fakeAuth', + namespace: 'fakeNS' + } + const fakeTVMOptions = { + some: 'options' + } + const cosmosTVMMock = jest.fn() + beforeEach(async () => { + TvmClient.mockReset() + TvmClient.init.mockReset() + cosmosTVMMock.mockReset() + TvmClient.init.mockResolvedValue({ + getAzureCosmosCredentials: cosmosTVMMock + }) + }) + test('when tvm options', async () => { + expect.hasAssertions() + cosmosTVMMock.mockResolvedValue(fakeTVMResponse) + await stateLib.init({ ow: fakeOWCreds, tvm: fakeTVMOptions }) + expect(TvmClient.init).toHaveBeenCalledTimes(1) + expect(TvmClient.init).toHaveBeenCalledWith({ ow: fakeOWCreds, ...fakeTVMOptions }) + expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) + expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeTVMResponse) + expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('openwhisk')) + checkInitDebugLogNoSecrets(fakeOWCreds.auth) + }) + test('when empty config to be able to pass OW creds as env variables', async () => { + expect.hasAssertions() + cosmosTVMMock.mockResolvedValue(fakeTVMResponse) + await stateLib.init() + expect(TvmClient.init).toHaveBeenCalledTimes(1) + expect(TvmClient.init).toHaveBeenCalledWith({ ow: undefined }) + expect(CosmosStateStore.init).toHaveBeenCalledTimes(1) + expect(CosmosStateStore.init).toHaveBeenCalledWith(fakeTVMResponse) + expect(global.mockLogDebug).toHaveBeenCalledWith(expect.stringContaining('openwhisk')) + }) + // eslint-disable-next-line jest/expect-expect + test('when tvm rejects with a 401 (throws wrapped error)', async () => { + expect.hasAssertions() + const e = new Error('tvm error') + e.sdkDetails = { fake: 'details', status: 401 } + cosmosTVMMock.mockRejectedValue(e) + await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds }), e.sdkDetails) + }) + // eslint-disable-next-line jest/expect-expect + test('when tvm rejects with a 403 (throws wrapped error)', async () => { + expect.hasAssertions() + const e = new Error('tvm error') + e.sdkDetails = { fake: 'details', status: 403 } + cosmosTVMMock.mockRejectedValue(e) + await global.expectToThrowForbidden(stateLib.init.bind(stateLib, { ow: fakeOWCreds }), e.sdkDetails) + }) + test('when tvm rejects with another status code (throws tvm error)', async () => { + expect.hasAssertions() + const tvmError = new Error('tvm error') + tvmError.sdkDetails = { fake: 'details', status: 500 } + cosmosTVMMock.mockRejectedValue(tvmError) + return expect(stateLib.init({ ow: fakeOWCreds })).rejects.toThrow(tvmError) + }) }) }) diff --git a/test/utils.test.js b/test/utils.test.js deleted file mode 100644 index 20f86db..0000000 --- a/test/utils.test.js +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2024 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 { withHiddenFields } = require('../lib/utils') - -describe('withHiddenFields', () => { - test('no params', () => { - expect(withHiddenFields()).toEqual(undefined) - }) - - test('object with undefined hidden fields', () => { - expect(withHiddenFields({})).toEqual({}) - }) - - test('object with non-array hidden fields', () => { - expect(withHiddenFields({}, 123)).toEqual({}) - }) - - test('object with no hidden fields', () => { - expect(withHiddenFields({}, [])).toEqual({}) - }) - - test('object with hidden fields', () => { - const src = { - foo: 'bar', - cat: 'bat' - } - const target = { - ...src, - cat: '' - } - - expect(withHiddenFields(src, ['cat'])).toEqual(target) - }) -})