diff --git a/.travis.yml b/.travis.yml index 0884795..04eebcf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ sudo: false language: node_js node_js: -- '8' +- '12' addons: sonarcloud: diff --git a/LICENSE b/LICENSE index 9dc814a..6f2b603 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Rogerio F. da Cunha (petruki) and Contributors +Copyright (c) 2019 Roger Floriano (petruki) and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index 3c055da..a06c173 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "./src/index.js", "types": "./src/index.d.ts", "author": { - "name": "Rogerio Petruki", + "name": "Roger Floriano", "email": "switcher.project@gmail.com" }, "keywords": [ @@ -40,6 +40,6 @@ "url": "https://github.com/petruki/switcher-client-master" }, "engines": { - "node": ">= 8.0.0" + "node": "^12.0.0" } } diff --git a/readme.md b/readme.md index f8a9e4a..0021c57 100644 --- a/readme.md +++ b/readme.md @@ -7,93 +7,112 @@ ![Switcher API: JavaScript Client: Cloud-based Feature Flag API](https://github.com/petruki/switcherapi-assets/blob/master/logo/switcherapi_js_client.png) -# Install -`npm install switcher-client` - # About -Module for working with Switcher-API. +Client JavaScript for working with Switcher-API. https://github.com/petruki/switcher-api -Switcher Client is a friendly lib to interact with the Switcher API by: -- Simplifying validations throughout your remote Switcher configuration. -- Able to work offline using a snapshot claimed from your remote Switcher-API. -- Able to run in silent mode that will prevent your application to not be 100% dependent on the online API. -- Being flexible in order to remove the complexity of multi-staging (add as many environments as you want). -- Being friendly by making possible to manipulate switchers without changing your online switchers. (useful for automated tests). -- Being secure by using OAuth 2 flow. Requests are made using tokens that will validate your domain, component, environment and API key. -Tokens have an expiration time and are not stored. The Switcher Client is responsible to renew it using your settings. - -# Example -1) Configure your client +- Able to work offline using a snapshot file downloaded from your remote Switcher-API Domain. +- Silent mode automatically enables a contingent sub-process in case of connectivity issues. +- Built-in mock implementation for automated testing. +- Easy to setup. Switcher Context is responsible to manage all the complexity between your application and API. + +# Usage + +## Install +`npm install switcher-client` + +## Module initialization +The context properties stores all information regarding connectivity and strategy settings. + ```js const Switcher = require("switcher-client"); const apiKey = 'API Key'; -const environment = 'default'; -const domain = 'Your Domain Name'; -const component = 'Android'; -const url = 'http://localhost:3000/criteria'; +const environment = 'default'; // Production = default +const domain = 'My Domain'; +const component = 'MyApp'; +const url = 'https://switcher-load-balance.herokuapp.com'; ``` -- **apiKey**: Obtained after creating your domain using the Switcher-API project. -- **environment**: You can run multiple environments. Production environment is 'default' which is created automatically after creating the domain. -- **domain**: This is your business name identification. -- **component**: This is the name of the application that will be using this API. -- **url**: Endpoint of your Swither-API. -2) Options - you can also activate features such as offline and silent mode +- **apiKey**: Switcher-API key generated after creating a domain.. +- **environment**: Environment name. Production environment is named as 'default'. +- **domain**: Domain name. +- **component**: Application name. +- **url**: Swither-API endpoint. + +## Options +You can also activate features such as offline and silent mode: + ```js const offline = true; -const snapshotLocation = './snapshot/default.json'; +const snapshotLocation = './snapshot/'; const silentMode = true; const retryAfter = '5m'; + +let switcher = new Switcher(url, apiKey, domain, component, environment, { + offline, snapshotLocation, silentMode, retryAfter +}); ``` + - **offline**: If activated, the client will only fetch the configuration inside your snapshot file. The default value is 'false'. -- **snapshotLocation**: Location of your snapshot. The default value is './snapshot/default.json'. -- **silentMode**: If activated, all connections errors will be ignored and the client will automatically fetch the configuration into your snapshot. -- **retryAfter** : Set the duration you want the client to try to reach the online API again. (see moment documentation for time signature). The default value is 5m. +- **snapshotLocation**: Location of snapshot files. The default value is './snapshot/'. +- **silentMode**: If activated, all connectivity issues will be ignored and the client will automatically fetch the configuration into your snapshot file. +- **retryAfter** : Time given to the module to re-establish connectivity with the API - e.g. 5s (s: seconds - m: minutes - h: hours). + + +## Executing +There are a few different ways to call the API using the JavaScript module. +Here are some examples: + +1. **No parameters** +Invoking the API can be done by instantiating the switcher and calling *isItOn* passing its key as a parameter. -3) Create the client ```js -const switcher = new Switcher(url, apiKey, domain, component, environment) -//or - using silent mode -const switcher = new Switcher(url, apiKey, domain, component, environment, { silentMode: true }) -//or - using offline mode -const switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true }) +const switcher = new Switcher(url, apiKey, domain, component, environment); +await switcher.isItOn('FEATURE01'); ``` -## Invoking switchers -**Scenario 1** +2. **Promise** +Using promise is another way to call the API if you want, like: -You want to setup the input of your switch before using it and call 'isItOn' some elsewhere. ```js -switcher.prepare('MY_KEY', [Switcher.StrategiesType.VALUE, 'USER_1') -switcher.isItOn() +switcher.isItOnPromise('KEY') + .then(result => console.log('Result:', result)) + .catch(error => console.log(error)); ``` -**Scenario 2** +3. **Strategy validation - preparing input** +Loading information into the switcher can be made by using *prepare*, in case you want to include input from a different place of your code. Otherwise, it is also possible to include everything in the same call. -You want to call isItOn without preparing, as simple as this: ```js -switcher.isItOn('KEY') +switcher.prepare('FEATURE01', [Switcher.StrategiesType.VALUE, 'USER_1'); +switcher.isItOn(); ``` -**Scenario 3** +4. **Strategy validation - all-in-one execution** +All-in-one method is fast and include everything you need to execute a complex call to the API. -Using promise is another way to call the API if you want: ```js -switcher.isItOnPromise('KEY') - .then(result => console.log('Promise result:', result)) - .catch(error => console.log(error)); +await switcher.isItOn('FEATURE01', + [Switcher.StrategiesType.VALUE, 'User 1', + Switcher.StrategiesType.NETWORK, '192.168.0.1'] +); ``` -## Bypassing switchers -You can also bypass your switcher configuration by invoking 'assume'. This is perfect for your test code where you want to test both scenarios when the switcher is true and false. +## Built-in mock feature +You can also bypass your switcher configuration by invoking 'Switcher.assume'. This is perfect for your test code where you want to test both scenarios when the switcher is true and false. + ```js -switcher.assume('KEY').true() -switcher.isItOn('KEY') // it is going to be true +Switcher.assume('FEATURE01').true(); +switcher.isItOn('FEATURE01'); // true + +Switcher.forget('FEATURE01'); +switcher.isItOn('FEATURE01'); // Now, it's going to return the result retrieved from the API or the Snaopshot file ``` -Invoke forget to remove any switch assumption, like this: +## Snapshot version check +For convenience, an implementation of a domain version checker is available if you have external processes that manage snapshot files. + ```js -switcher.forget('KEY') +switcher.checkSnapshot(); ``` \ No newline at end of file diff --git a/snapshot/dev.json b/snapshot/dev.json new file mode 100644 index 0000000..a83c8ba --- /dev/null +++ b/snapshot/dev.json @@ -0,0 +1,26 @@ +{ + "data": { + "domain": { + "name": "currency-api", + "description": "Currency API", + "version": 1588557288037, + "activated": true, + "group": [ + { + "name": "Rollout 2030", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2030", + "description": "Feature Flag", + "activated": false, + "strategies": [], + "components": [] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index 79f5d52..ddaa540 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,6 +10,7 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sources=src sonar.tests=test sonar.language=js +sonar.exclusions=src/**/*.d.ts sonar.dynamicAnalysis=reuseReports # Encoding of the source code. Default is default system encoding diff --git a/src/index.d.ts b/src/index.d.ts index c6d857b..8b7bb38 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,6 +1,14 @@ +import Key from "./lib/key"; + declare class Switcher { - constructor(url: string, apiKey: string, domain: string, component: string, environment: string, options?: SwitcherOptions); + constructor( + url: string, + apiKey: string, + domain: string, + component: string, + environment: string, + options?: SwitcherOptions); url: string; token: string; @@ -10,14 +18,66 @@ declare class Switcher { key: string; input: string[]; exp: number; - bypassedKeys: Key; + snapshot?: string; + /** + * Validate the input provided to access the API + */ validate(): void; + + /** + * Pre-set input values before calling the API + * + * @param key + * @param input + */ prepare(key: string, input?: string[]): void; + + /** + * Execute async criteria + * + * @param key + * @param input + */ isItOn(key?: string, input?: string[]): boolean; + + /** + * Execute async criteria + * + * @param key + * @param input + */ isItOnPromise(key?: string, input?: string[]): Promise; - assume(key: string): Key; - forget(key: string): void; + + /** + * Read snapshot file locally and store in a parsed JSON object + */ + loadSnapshot(): void; + + /** + * Verifies if the current snapshot file is updated. + * Return true if an update has been made. + */ + checkSnapshot(): boolean; + + /** + * Remove snapshot from real-time update + */ + unloadSnapshot(): void; + + /** + * Force a switcher value to return a given value by calling one of both methods - true() false() + * + * @param key + */ + static assume(key: string): Key; + + /** + * Remove forced value from a switcher + * + * @param key + */ + static forget(key: string): void; } declare interface SwitcherOptions { @@ -27,17 +87,4 @@ declare interface SwitcherOptions { retryAfter: string; } -declare class Key { - - constructor(key: string); - - key: string; - valaue: boolean; - - true(): void; - false(): void; - getKey(): string; - getValue(): boolean; -} - export = Switcher; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 106de4f..c989583 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,15 @@ "use strict"; -const services = require('./utils/services') -const { loadDomain, processOperation, StrategiesType } = require('./utils/index') +const Bypasser = require('./lib/bypasser'); +const { loadDomain, StrategiesType } = require('./utils/index'); +const services = require('./lib/services'); +const checkCriteriaOffline = require('./lib/resolver'); +const validateSnapshot = require('./lib/snapshot'); +const fs = require('fs'); + +const DEFAULT_SNAPSHOT_LOCATION = './snapshot/'; +const DEFAULT_RETRY_TIME = '5m'; +const DEFAULT_OFFLINE = false; class Switcher { @@ -13,8 +21,8 @@ class Switcher { this.environment = environment; // Default values - this.offline = false; - this.snapshotLocation = './snapshot/default.json'; + this.offline = DEFAULT_OFFLINE; + this.snapshotLocation = DEFAULT_SNAPSHOT_LOCATION; if (options) { if ('offline' in options) { @@ -33,12 +41,12 @@ class Switcher { this.retryTime = options.retryAfter.slice(0, -1); this.retryDurationIn = options.retryAfter.slice(-1); } else { - this.retryTime = 5; - this.retryDurationIn = 'm'; + this.retryTime = DEFAULT_RETRY_TIME.charAt(0); + this.retryDurationIn = DEFAULT_RETRY_TIME.charAt(1); } } - this.bypassedKeys = new Array(); + this.loadSnapshot(); } async prepare(key, input) { @@ -70,11 +78,11 @@ class Switcher { } if (!this.key) { - errors.push('Missing key field'); + errors.push('Missing key field'); } if (!this.url) { - errors.push('Missing url field'); + errors.push('Missing url field'); } if (!this.exp || Date.now() > (this.exp*1000)) { @@ -86,19 +94,19 @@ class Switcher { } if (errors.length) { - throw new Error(`Something went wrong: ${errors.join(', ')}`) + throw new Error(`Something went wrong: ${errors.join(', ')}`); } } async isItOn(key, input) { - const bypassKey = searchBypassed(this.key ? this.key : key, this.bypassedKeys); + const bypassKey = Bypasser.searchBypassed(this.key ? this.key : key); if (bypassKey) { return bypassKey.getValue(); } if (this.offline) { return await checkCriteriaOffline( - this.key ? this.key : key, this.input ? this.input : input, this.snapshotLocation); + this.key ? this.key : key, this.input ? this.input : input, this.snapshot); } if (key) { this.key = key; } @@ -107,123 +115,70 @@ class Switcher { await this.validate(); if (this.token === 'SILENT') { return await checkCriteriaOffline( - this.key ? this.key : key, this.input ? this.input : input, this.snapshotLocation); + this.key ? this.key : key, this.input ? this.input : input, this.snapshot); } else { return await services.checkCriteria(this.url, this.token, this.key, this.input); } } - isItOnPromise(key, input) { - return new Promise((resolve) => resolve(this.isItOn(key, input))); - } + async checkSnapshot() { + if (!this.exp || Date.now() > (this.exp*1000)) { + const response = await services.auth(this.url, this.apiKey, this.domain, this.component, this.environment, { + silentMode: this.silentMode, + retryTime: this.retryTime, + retryDurationIn: this.retryDurationIn + }); + + this.token = response.token; + this.exp = response.exp; - assume(key) { - - const existentKey = searchBypassed(key, this.bypassedKeys); - if (existentKey) { - return existentKey; + const result = await validateSnapshot( + this.url, this.token, this.domain, this.environment, this.snapshotLocation, this.snapshot.data.domain.version); + + if (result) { + this.loadSnapshot(); + return true; + } } - - const keyBypassed = new Key(key); - this.bypassedKeys.push(keyBypassed) - return keyBypassed; - } - - forget(key) { - this.bypassedKeys.splice(this.bypassedKeys.indexOf(searchBypassed(key, this.bypassedKeys)), 1); - } - - static get StrategiesType() { - return StrategiesType; - } - -} - -class Key { - constructor(key) { - this.key = key; - this.value = undefined; - } - - true() { - this.value = true; + return false; } - false() { - this.value = false; + isItOnPromise(key, input) { + return new Promise((resolve) => resolve(this.isItOn(key, input))); } - getKey() { - return this.key; - } + loadSnapshot() { + if (this.snapshotLocation) { + const snapshotFile = `${this.snapshotLocation}${this.environment}.json`; + this.snapshot = loadDomain(snapshotFile); - getValue() { - return this.value; + fs.unwatchFile(snapshotFile); + fs.watchFile(snapshotFile, (curr, prev) => { + this.snapshot = loadDomain(snapshotFile); + }); + } } -} -function searchBypassed(key, bypassedKeys) { - let existentKey; - bypassedKeys.forEach(async bk => { - if (bk.getKey() === key) { - return existentKey = bk; + unloadSnapshot() { + if (this.snapshotLocation) { + const snapshotFile = `${this.snapshotLocation}${this.environment}.json`; + this.snapshot = undefined; + fs.unwatchFile(snapshotFile); } - }) - return existentKey; -} - -async function checkCriteriaOffline(key, input, snapshotLocation) { - const { data } = await loadDomain(snapshotLocation); - return await resolveCriteria(key, input, data); -} - -async function resolveCriteria(key, input, { domain }) { - if (!domain.activated) { - return false; } - let result = true; - let notFoundKey = true; - const { group } = domain; - if (group) { - group.forEach(function (g) { - - const { config } = g; - const configFound = config.filter(c => c.key === key) - - if (configFound[0]) { - notFoundKey = false; - if (!g.activated) { - return result = false; - } - - if (!configFound[0].activated) { - return result = false; - } - - const { strategies } = configFound[0] - strategies.forEach(function(strategy) { - if (strategy.activated) { - if (!input) { - return result = false; - } - - const entry = services.getEntry(input); - const entryInput = entry.filter(e => e.strategy === strategy.strategy) - if (!processOperation(strategy.strategy, strategy.operation, entryInput[0].input, strategy.values)) { - return result = false; - } - } - }) - } - }) + static get StrategiesType() { + return StrategiesType; } - if (notFoundKey) { - throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`) + static assume(key) { + return Bypasser.assume(key); } - return result; + static forget(key) { + return Bypasser.forget(key); + } + } module.exports = Switcher; \ No newline at end of file diff --git a/src/lib/bypasser.d.ts b/src/lib/bypasser.d.ts new file mode 100644 index 0000000..fe49aa0 --- /dev/null +++ b/src/lib/bypasser.d.ts @@ -0,0 +1,29 @@ +import Key from "./key"; + +declare class Bypasser { + + static bypassedKeys: Key; + + /** + * Force a switcher value to return a given value by calling one of both methods - true() false() + * + * @param key + */ + static assume(key: string): Key; + + /** + * Remove forced value from a switcher + * + * @param key + */ + static forget(key: string): void; + + /** + * Search for key registered via 'assume' + * + * @param key + */ + static searchBypassed(key: string): Key; +} + +export = Bypasser; \ No newline at end of file diff --git a/src/lib/bypasser.js b/src/lib/bypasser.js new file mode 100644 index 0000000..c4f50fd --- /dev/null +++ b/src/lib/bypasser.js @@ -0,0 +1,35 @@ +"use strict"; + +const Key = require('./key'); + +class Bypasser { + static bypassedKeys = new Array(); + + static assume(key) { + const existentKey = this.searchBypassed(key, Bypasser.bypassedKeys); + if (existentKey) { + return existentKey; + } + + const keyBypassed = new Key(key); + Bypasser.bypassedKeys.push(keyBypassed); + return keyBypassed; + } + + static forget(key) { + Bypasser.bypassedKeys.splice( + Bypasser.bypassedKeys.indexOf(this.searchBypassed(key, Bypasser.bypassedKeys)), 1); + } + + static searchBypassed(key) { + let existentKey; + Bypasser.bypassedKeys.forEach(async bk => { + if (bk.getKey() === key) { + return existentKey = bk; + } + }); + return existentKey; + } +} + +module.exports = Bypasser; \ No newline at end of file diff --git a/src/lib/key.d.ts b/src/lib/key.d.ts new file mode 100644 index 0000000..12e1a9d --- /dev/null +++ b/src/lib/key.d.ts @@ -0,0 +1,13 @@ +declare class Key { + constructor(key: string); + + key: string; + valaue: boolean; + + true(): void; + false(): void; + getKey(): string; + getValue(): boolean; +} + +export = Key; \ No newline at end of file diff --git a/src/lib/key.js b/src/lib/key.js new file mode 100644 index 0000000..6bac299 --- /dev/null +++ b/src/lib/key.js @@ -0,0 +1,26 @@ +"use strict"; + +class Key { + constructor(key) { + this.key = key; + this.value = undefined; + } + + true() { + this.value = true; + } + + false() { + this.value = false; + } + + getKey() { + return this.key; + } + + getValue() { + return this.value; + } +} + +module.exports = Key; \ No newline at end of file diff --git a/src/lib/resolver.js b/src/lib/resolver.js new file mode 100644 index 0000000..3793553 --- /dev/null +++ b/src/lib/resolver.js @@ -0,0 +1,60 @@ +const { processOperation } = require('../utils/index'); +const services = require('../lib/services'); + +async function resolveCriteria(key, input, { + domain +}) { + if (!domain.activated) { + return false; + } + + let result = true; + let notFoundKey = true; + const { group } = domain; + if (group) { + group.forEach(function (g) { + + const { config } = g; + const configFound = config.filter(c => c.key === key) + + if (configFound[0]) { + notFoundKey = false; + if (!g.activated) { + return result = false; + } + + if (!configFound[0].activated) { + return result = false; + } + + const { strategies } = configFound[0] + strategies.forEach(function (strategy) { + if (strategy.activated) { + if (!input) { + return result = false; + } + + const entry = services.getEntry(input); + const entryInput = entry.filter(e => e.strategy === strategy.strategy) + if (!processOperation(strategy.strategy, strategy.operation, entryInput[0].input, strategy.values)) { + return result = false; + } + } + }) + } + }) + } + + if (notFoundKey) { + throw new Error(`Something went wrong: {"error":"Unable to load a key ${key}"}`); + } + + return result; +} + +async function checkCriteriaOffline(key, input, snapshot) { + const { data } = snapshot; + return await resolveCriteria(key, input, data); +} + +module.exports = checkCriteriaOffline; \ No newline at end of file diff --git a/src/lib/services.js b/src/lib/services.js new file mode 100644 index 0000000..27f2fad --- /dev/null +++ b/src/lib/services.js @@ -0,0 +1,171 @@ +const request = require('request-promise'); +const moment = require('moment'); + +exports.getEntry = (input) => { + if (!input) { + return undefined; + } + + if (input.length % 2 !== 0) { + throw new Error(`Invalid input format for '${input}'`); + } + + let entry = []; + + for (var i = 0; i < input.length; i += 2) { + entry.push({ + strategy: input[i], + input: input[i + 1] + }); + } + + return entry; +} + +exports.checkCriteria = async (url, token, key, input) => { + try { + const entry = this.getEntry(input); + const options = { + url: `${url}/criteria`, + qs: { + key + }, + headers: { + 'Authorization': `Bearer ${token}` + }, + json: true + }; + + if (entry) { + options.body = { + entry + }; + } + + const response = await request.post(options); + return response.result; + } catch (e) { + let error; + if (e.error) { + error = JSON.stringify(e.error); + } + throw new CriteriaError(e.error ? error : e.message); + } +} + +exports.auth = async (url, apiKey, domain, component, environment, options) => { + try { + const postOptions = { + url: `${url}/criteria/auth`, + headers: { + 'switcher-api-key': apiKey + }, + json: true, + body: { + domain, + component, + environment + } + }; + + return await request.post(postOptions); + } catch (e) { + if (e.error.code === 'ECONNREFUSED' && options && 'silentMode' in options) { + if (options.silentMode) { + const expirationTime = moment().add(options.retryTime, options.retryDurationIn); + return { + token: 'SILENT', + exp: expirationTime.toDate().getTime() / 1000 + }; + } + } + + let error; + if (e.error) { + error = JSON.stringify(e.error); + } + throw new AuthError(e.error ? error : e.message); + } +} + +exports.checkSnapshotVersion = async (url, token, version) => { + try { + const options = { + url: `${url}/criteria/snapshot_check/${version}`, + headers: { + 'Authorization': `Bearer ${token}` + }, + json: true + }; + + const response = await request.get(options); + return response; + } catch (e) { + let error; + if (e.error) { + error = JSON.stringify(e.error); + } + throw new SnapshotServiceError(e.error ? error : e.message); + } +} + +exports.resolveSnapshot = async (url, token, domain, environment) => { + var query = `query domain($domain: String!, $environment: String!) { + domain(name: $domain, environment: $environment) { + name version activated + group { name activated + config { key activated + strategies { strategy activated operation values } + components + } + } + } + }`; + + try { + const options = { + url: `${url}/graphql`, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + query, + variables: { domain, environment } + }) + }; + + const response = await request.post(options); + return JSON.stringify(JSON.parse(response), null, 4); + } catch (e) { + let error; + if (e.error) { + error = JSON.stringify(e.error); + } + throw new SnapshotServiceError(e.error ? error : e.message); + } +} + +class AuthError extends Error { + constructor(message) { + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +class CriteriaError extends Error { + constructor(message) { + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +class SnapshotServiceError extends Error { + constructor(message) { + super(`Something went wrong: ${message}`); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file diff --git a/src/lib/snapshot.js b/src/lib/snapshot.js new file mode 100644 index 0000000..5dee0a0 --- /dev/null +++ b/src/lib/snapshot.js @@ -0,0 +1,16 @@ +const { resolveSnapshot, checkSnapshotVersion } = require('./services'); +const fs = require('fs'); + +async function validateSnapshot(url, token, domain, environment, snapshotLocation, snapshotVersion) { + const { status } = await checkSnapshotVersion(url, token, snapshotVersion); + + if (!status) { + const snapshot = await resolveSnapshot(url, token, domain, environment); + + fs.writeFileSync(`${snapshotLocation}${environment}.json`, snapshot); + return true; + } + return false; +} + +module.exports = validateSnapshot; \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js index e786e95..889df05 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,14 +1,14 @@ -const fs = require('fs') +const fs = require('fs'); const moment = require('moment'); const IPCIDR = require('ip-cidr'); const loadDomain = (snapshotLocation) => { try { - const dataBuffer = fs.readFileSync(snapshotLocation) - const dataJSON = dataBuffer.toString() - return JSON.parse(dataJSON) + const dataBuffer = fs.readFileSync(snapshotLocation); + const dataJSON = dataBuffer.toString(); + return JSON.parse(dataJSON); } catch (e) { - throw new Error(`Something went wrong: It was not possible to load the file at ${snapshotLocation}`) + throw new Error(`Something went wrong: It was not possible to load the file at ${snapshotLocation}`); } } @@ -32,19 +32,19 @@ const OperationsType = Object.freeze({ const processOperation = (strategy, operation, input, values) => { switch(strategy) { case StrategiesType.NETWORK: - return processNETWORK(operation, input, values) + return processNETWORK(operation, input, values); case StrategiesType.VALUE: - return processVALUE(operation, input, values) + return processVALUE(operation, input, values); case StrategiesType.TIME: - return processTime(operation, input, values) + return processTime(operation, input, values); case StrategiesType.DATE: - return processDate(operation, input, values) + return processDate(operation, input, values); } } function processNETWORK(operation, input, values) { - const cidrRegex = '^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$' + const cidrRegex = '^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$'; switch(operation) { case OperationsType.EXIST: @@ -52,10 +52,10 @@ function processNETWORK(operation, input, values) { if (values[i].match(cidrRegex)) { const cidr = new IPCIDR(values[i]); if (cidr.contains(input)) { - return true + return true; } } else { - return values.includes(input) + return values.includes(input); } } break; @@ -64,13 +64,13 @@ function processNETWORK(operation, input, values) { if (element.match(cidrRegex)) { const cidr = new IPCIDR(element); if (cidr.contains(input)) { - return true + return true; } } else { - return values.includes(input) + return values.includes(input); } }) - return result.length === 0 + return result.length === 0; } return false @@ -79,38 +79,38 @@ function processNETWORK(operation, input, values) { function processVALUE(operation, input, values) { switch(operation) { case OperationsType.EXIST: - return values.includes(input) + return values.includes(input); case OperationsType.NOT_EXIST: - return !values.includes(input) + return !values.includes(input); case OperationsType.EQUAL: - return input === values[0] + return input === values[0]; case OperationsType.NOT_EQUAL: - const result = values.filter(element => element === input) - return result.length === 0 + const result = values.filter(element => element === input); + return result.length === 0; } } function processTime(operation, input, values) { - const today = moment().format('YYYY-MM-DD') + const today = moment().format('YYYY-MM-DD'); switch(operation) { case OperationsType.LOWER: - return moment(`${today}T${input}`).isSameOrBefore(`${today}T${values[0]}`) + return moment(`${today}T${input}`).isSameOrBefore(`${today}T${values[0]}`); case OperationsType.GREATER: - return moment(`${today}T${input}`).isSameOrAfter(`${today}T${values[0]}`) + return moment(`${today}T${input}`).isSameOrAfter(`${today}T${values[0]}`); case OperationsType.BETWEEN: - return moment(`${today}T${input}`).isBetween(`${today}T${values[0]}`, `${today}T${values[1]}`) + return moment(`${today}T${input}`).isBetween(`${today}T${values[0]}`, `${today}T${values[1]}`); } } function processDate(operation, input, values) { switch(operation) { case OperationsType.LOWER: - return moment(input).isSameOrBefore(values[0]) + return moment(input).isSameOrBefore(values[0]); case OperationsType.GREATER: - return moment(input).isSameOrAfter(values[0]) + return moment(input).isSameOrAfter(values[0]); case OperationsType.BETWEEN: - return moment(input).isBetween(values[0], values[1]) + return moment(input).isBetween(values[0], values[1]); } } @@ -119,4 +119,4 @@ module.exports = { processOperation, StrategiesType, OperationsType -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/utils/services.js b/src/utils/services.js deleted file mode 100644 index 4867b03..0000000 --- a/src/utils/services.js +++ /dev/null @@ -1,110 +0,0 @@ -const request = require('request-promise'); -const moment = require('moment'); - -exports.getEntry = (input) => { - if (!input) { - return undefined - } - - if (input.length % 2 !== 0) { - throw new Error(`Invalid input format for '${input}'`) - } - - let entry = []; - - for (var i = 0; i < input.length; i += 2) { - entry.push({ - strategy: input[i], - input: input[i + 1] - }) - } - - return entry -} - -exports.checkCriteria = async (url, token, key, input) => { - try { - const entry = this.getEntry(input) - const options = { - url: `${url}/criteria`, - qs: { - key - }, - headers: { - 'Authorization': `Bearer ${token}` - }, - json: true - } - - if (entry) { - options.body = { - entry - } - } - - const response = await request.post(options); - return response.result; - } catch (e) { - let error - if (e.error) { - error = JSON.stringify(e.error) - } else { - error = e.message - } - throw new CriteriaError(error) - } -} - -exports.auth = async (url, apiKey, domain, component, environment, options) => { - try { - const postOptions = { - url: `${url}/criteria/auth`, - headers: { - 'switcher-api-key': apiKey - }, - json: true, - body: { - domain, - component, - environment - } - } - - return await request.post(postOptions); - } catch (e) { - if (e.error.code === 'ECONNREFUSED' && options && 'silentMode' in options) { - if (options.silentMode) { - const expirationTime = moment().add(options.retryTime, options.retryDurationIn); - return { - token: 'SILENT', - exp: expirationTime.toDate().getTime() / 1000 - } - } - } - - let error - if (e.error) { - error = JSON.stringify(e.error) - } else { - error = e.message - } - - throw new AuthError(error) - } -} - -class AuthError extends Error { - constructor(message) { - super(`Something went wrong: ${message}`) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) - } -} - -class CriteriaError extends Error { - constructor(message) { - super(`Something went wrong: ${message}`) - this.name = this.constructor.name - Error.captureStackTrace(this, this.constructor) - } -} \ No newline at end of file diff --git a/test/playground/index.js b/test/playground/index.js index aa6b2e7..eeebfc1 100644 --- a/test/playground/index.js +++ b/test/playground/index.js @@ -1,21 +1,38 @@ -const Switcher = require('../../src/index') +const Switcher = require('../../src/index'); -let switcher = new Switcher(); +let switcher; -function setupSwitcher() { - const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S'; - const domain = 'My Domain'; - const component = 'Android'; - const environment = 'default'; +function setupSwitcher(offline) { + const apiKey = '$2b$08$7U/KJBVgG.FQtYEKKnbLe.o6p7vBrfHFRgMipZTaokSmVFiduXq/y' + // const apiKey = '$2b$08$m.8yx5ekyqWnAGgZjvG/AOTaMO3l1riBO/r4fHQ4EHqM87TdvHU9S' + const domain = 'My Domain' + const component = 'Android' + const environment = 'default' const url = 'http://localhost:3000' switcher = new Switcher(url, apiKey, domain, component, environment, { - offline: false - }) + offline + }); } -const main = async () => { - setupSwitcher(); +// Requires online API +const testSnapshotUpdate = async () => { + setupSwitcher(false); + + let result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + await switcher.checkSnapshot(); + await new Promise(resolve => setTimeout(resolve, 1000)); + + result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + switcher.unloadSnapshot(); +} + +const testAsyncCall = async () => { + setupSwitcher(true); let result = await switcher.isItOn('FEATURE2020'); console.log(result); @@ -27,6 +44,25 @@ const main = async () => { switcher.assume('FEATURE2020').false(); result = await switcher.isItOn('FEATURE2020'); console.log('Value changed:', result); + + switcher.unloadSnapshot(); +} + +const testBypasser = async () => { + setupSwitcher(true); + + let result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + Switcher.assume('FEATURE2020').false(); + result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + Switcher.forget('FEATURE2020'); + result = await switcher.isItOn('FEATURE2020'); + console.log(result); + + switcher.unloadSnapshot(); } -main() \ No newline at end of file +testBypasser(); \ No newline at end of file diff --git a/test/playground/snapshot/default.json b/test/playground/snapshot/default.json new file mode 100644 index 0000000..16e2156 --- /dev/null +++ b/test/playground/snapshot/default.json @@ -0,0 +1,80 @@ +{ + "data": { + "domain": { + "name": "My Domain", + "version": 1588557288037, + "description": "Technology solutions - Test 1\n\n$2b$08$CkraR0sT8JNcKPgLD6FJBuDf.JE4aUXqMloYFGFcl04sw8RcAwywm", + "activated": true, + "group": [ + { + "name": "Project 1", + "description": "Project #1 description", + "activated": true, + "config": [ + { + "key": "FEATURE01", + "description": "Feature #1 description", + "activated": false, + "strategies": [ + { + "strategy": "VALUE_VALIDATION", + "activated": true, + "operation": "EXIST", + "values": [ + "TEST" + ] + } + ], + "components": [ + "Android", + "iOS", + "Windows" + ] + }, + { + "key": "FEATURE02", + "description": "Feature #2 description", + "activated": true, + "strategies": [ + { + "strategy": "VALUE_VALIDATION", + "activated": true, + "operation": "EQUAL", + "values": [ + "USA" + ] + } + ], + "components": [] + } + ] + }, + { + "name": "Project 2", + "description": "Project #2 description", + "activated": true, + "config": [ + { + "key": "FEATURE2020", + "description": "Feature #22020 description", + "activated": true, + "strategies": [ + { + "strategy": "NETWORK_VALIDATION", + "activated": false, + "operation": "NOT_EXIST", + "values": [ + "10.0.0.3/21" + ] + } + ], + "components": [ + "Android" + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/playground/snapshot/dev.json b/test/playground/snapshot/dev.json new file mode 100644 index 0000000..a83c8ba --- /dev/null +++ b/test/playground/snapshot/dev.json @@ -0,0 +1,26 @@ +{ + "data": { + "domain": { + "name": "currency-api", + "description": "Currency API", + "version": 1588557288037, + "activated": true, + "group": [ + { + "name": "Rollout 2030", + "description": "Changes that will be applied during the rollout", + "activated": true, + "config": [ + { + "key": "FF2FOR2030", + "description": "Feature Flag", + "activated": false, + "strategies": [], + "components": [] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/test.js b/test/test.js index 02103af..777beb0 100644 --- a/test/test.js +++ b/test/test.js @@ -1,98 +1,104 @@ -"use strict" - const assert = require('chai').assert const sinon = require('sinon'); -const Switcher = require('../src/index') -const request = require('request-promise') -const services = require('../src/utils/services') +const Switcher = require('../src/index'); +const request = require('request-promise'); +const services = require('../src/lib/services'); +const fs = require('fs'); // const { StrategiesType } = require('../src/utils/index') describe('E2E test - Switcher offline:', function () { - let switcher = new Switcher(); + let switcher; const apiKey = '$2b$08$S2Wj/wG/Rfs3ij0xFbtgveDtyUAjML1/TOOhocDg5dhOaU73CEXfK'; const domain = 'currency-api'; const component = 'Android'; const environment = 'default'; - const url = 'http://localhost:3000' + const url = 'http://localhost:3000'; this.beforeAll(function() { switcher = new Switcher(url, apiKey, domain, component, environment, { offline: true - }) - }) + }); + }); - it('Should be valid', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + this.afterAll(function() { + fs.unwatchFile('./snapshot/default.json'); + }); + + it('should be valid', async function () { + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); await switcher.isItOn('FF2FOR2020').then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); - it('Should be valid - No prepare function called', async function () { + it('should be valid - No prepare function called', async function () { switcher.isItOn('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']).then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); - it('Should be valid - No prepare function called (no input as well)', function () { + it('should be valid - No prepare function called (no input as well)', function () { switcher.isItOn('FF2FOR2030').then(function (result) { - assert.isTrue(result) + assert.isTrue(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); - it('Should be invalid - Input (IP) does not match', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '192.168.0.2']) + it('should be invalid - Input (IP) does not match', async function () { + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '192.168.0.2']); switcher.isItOn().then(function (result) { - assert.isFalse(result) + assert.isFalse(result); }, function (error) { console.log('Rejected:', error); - }) - }) + }); + }); - it('Should be valid assuming key to be false and then forgetting it', async function () { - await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + it('should be valid assuming key to be false and then forgetting it', async function () { + await switcher.prepare('FF2FOR2020', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); - assert.isTrue(await switcher.isItOn()) - switcher.assume('FF2FOR2020').false() - assert.isFalse(await switcher.isItOn()) - switcher.forget('FF2FOR2020') - assert.isTrue(await switcher.isItOn()) + assert.isTrue(await switcher.isItOn()); + Switcher.assume('FF2FOR2020').false(); + assert.isFalse(await switcher.isItOn()); + Switcher.forget('FF2FOR2020'); + assert.isTrue(await switcher.isItOn()); }) - it('Should be valid assuming unknown key to be true', async function () { - await switcher.prepare('UNKNOWN', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']) + it('should be valid assuming unknown key to be true', async function () { + await switcher.prepare('UNKNOWN', [Switcher.StrategiesType.VALUE, 'Japan', Switcher.StrategiesType.NETWORK, '10.0.0.3']); - switcher.assume('UNKNOWN').true() - assert.isTrue(await switcher.isItOn()) + Switcher.assume('UNKNOWN').true(); + assert.isTrue(await switcher.isItOn()); - switcher.forget('UNKNOWN') + Switcher.forget('UNKNOWN'); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"error":"Unable to load a key UNKNOWN"}', error.message) - }) - }) + assert.equal('Something went wrong: {"error":"Unable to load a key UNKNOWN"}', error.message); + }); + }); + + it('should be invalid - Offline file not found', async function () { + try { + new Switcher(url, apiKey, domain, component, environment, { + offline: true, + snapshotLocation: 'somewhere/' + }); + } catch (error) { + assert.equal('Something went wrong: It was not possible to load the file at somewhere/default.json', error.message); + } + }); +}); - it('Should be invalid - Offline file not found', async function () { - const offlineSwitcher = new Switcher(url, apiKey, domain, component, environment, { - offline: true, - snapshotLocation: 'somewhere/snapshot.json' - }) +describe('Unit test - Switcher:', function () { - await offlineSwitcher.isItOn('FF2FOR2020').then(function (result) { - }, function (error) { - assert.equal('Something went wrong: It was not possible to load the file at somewhere/snapshot.json', error.message) - }) + this.afterAll(function() { + fs.unwatchFile('./snapshot/default.json'); }) -}) - -describe('Unit test - Switcher:', function () { describe('check criteria:', function () { @@ -115,21 +121,21 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); - await switcher.prepare('FLAG_1', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) - assert.isTrue(await switcher.isItOn()) - }) + await switcher.prepare('FLAG_1', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); + assert.isTrue(await switcher.isItOn()); + }); it('should renew the token after expiration', async function () { this.timeout(3000); requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+1000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - const spyPrepare = sinon.spy(switcher, 'prepare') + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + const spyPrepare = sinon.spy(switcher, 'prepare'); // Prepare the call generating the token - await switcher.prepare('MY_FLAG') - await switcher.isItOn() + await switcher.prepare('MY_FLAG'); + await switcher.isItOn(); // The program delay 2 secs later for the next call await new Promise(resolve => setTimeout(resolve, 2000)); @@ -138,80 +144,80 @@ describe('Unit test - Switcher:', function () { clientAuth.returns(Promise.resolve({ token: 'asdad12d2232d2323f', exp: (Date.now()+1000)/1000 })); // In this time period the expiration time has reached, it should call prepare once again to renew the token - await switcher.isItOn() - assert.equal(spyPrepare.callCount, 2) + await switcher.isItOn(); + assert.equal(spyPrepare.callCount, 2); // In the meantime another call is made by the time the token is still not expired, so there is no need to call prepare again - await switcher.isItOn() - assert.equal(spyPrepare.callCount, 2) - }) + await switcher.isItOn(); + assert.equal(spyPrepare.callCount, 2); + }); it('should be valid - when sending key without calling prepare', async function () { requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - assert.isTrue(await switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])) - }) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + assert.isTrue(await switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])); + }); it('should be valid - when preparing key and sending input strategy afterwards', async function () { requestStub.returns(Promise.resolve({ result: true })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare('MY_FLAG') - assert.isTrue(await switcher.isItOn(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])) - }) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare('MY_FLAG'); + assert.isTrue(await switcher.isItOn(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1'])); + }); it('should be invalid - Missing url field', async function () { - let switcher = new Switcher(undefined, 'apiKey', 'domain', 'component', 'default') + let switcher = new Switcher(undefined, 'apiKey', 'domain', 'component', 'default'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing url field', error.message) - }) - }) + assert.equal('Something went wrong: Missing url field', error.message); + }); + }); it('should be invalid - Missing API Key field', async function () { - let switcher = new Switcher('url', undefined, 'domain', 'component', 'default') + let switcher = new Switcher('url', undefined, 'domain', 'component', 'default'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + await switcher.prepare('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing API Key field', error.message) - }) - }) + assert.equal('Something went wrong: Missing API Key field', error.message); + }); + }); it('should be invalid - Missing key field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare(undefined, [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing key field', error.message) - }) - }) + assert.equal('Something went wrong: Missing key field', error.message); + }); + }); it('should be invalid - Missing component field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', undefined, 'default') + let switcher = new Switcher('url', 'apiKey', 'domain', undefined, 'default'); switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']).then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing component field', error.message) - }) - }) + assert.equal('Something went wrong: Missing component field', error.message); + }); + }); it('should be invalid - Missing token field', async function () { requestStub.returns(Promise.resolve({ result: undefined })); @@ -219,24 +225,24 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') switcher.isItOn('MY_FLAG', [Switcher.StrategiesType.VALUE, 'User 1', Switcher.StrategiesType.NETWORK, '192.168.0.1']).then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Missing token field', error.message) - }) - }) + assert.equal('Something went wrong: Missing token field', error.message); + }); + }); it('should be invalid - bad strategy input', async function () { requestStub.returns(Promise.resolve({ result: undefined })); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') - await switcher.prepare('MY_WRONG_FLAG', ['THIS IS WRONG']) + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); + await switcher.prepare('MY_WRONG_FLAG', ['THIS IS WRONG']); switcher.isItOn().then(function (result) { - assert.isUndefined(result) + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: Invalid input format for \'THIS IS WRONG\'', error.message) - }) - }) + assert.equal('Something went wrong: Invalid input format for \'THIS IS WRONG\'', error.message); + }); + }); it('should run in silent mode', async function () { requestStub.restore(); @@ -255,28 +261,28 @@ describe('Unit test - Switcher:', function () { let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default', { silentMode: true, retryAfter: '1s' - }) - const spyPrepare = sinon.spy(switcher, 'prepare') + }); + const spyPrepare = sinon.spy(switcher, 'prepare'); // First attempt to reach the online API - Since it's configured to use silent mode, it should return true (according to the snapshot) - let result = await switcher.isItOn('FF2FOR2030') - assert.equal(result, true) + let result = await switcher.isItOn('FF2FOR2030'); + assert.equal(result, true); await new Promise(resolve => setTimeout(resolve, 500)); // The call below is in silent mode. It is getting the configuration from the offline snapshot again - result = await switcher.isItOn() - assert.equal(result, true) + result = await switcher.isItOn(); + assert.equal(result, true); // As the silent mode was configured to retry after 3 seconds, it's still in time, // therefore, it's not required to try to reach the online API yet. - assert.equal(spyPrepare.callCount, 1) + assert.equal(spyPrepare.callCount, 1); await new Promise(resolve => setTimeout(resolve, 1000)); // Silent mode has expired its time. Again, the online API is still offline. Prepare is called once again. - result = await switcher.isItOn() - assert.equal(result, true) - assert.equal(spyPrepare.callCount, 2) + result = await switcher.isItOn(); + assert.equal(result, true); + assert.equal(spyPrepare.callCount, 2); await new Promise(resolve => setTimeout(resolve, 1500)); @@ -285,9 +291,9 @@ describe('Unit test - Switcher:', function () { clientAuth = sinon.stub(services, 'auth'); clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); - result = await switcher.isItOn() - assert.equal(result, false) - }) + result = await switcher.isItOn(); + assert.equal(result, false); + }); it('should throw error if not in silent mode', async function () { requestStub.restore(); @@ -302,15 +308,113 @@ describe('Unit test - Switcher:', function () { } }); - let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default') + let switcher = new Switcher('url', 'apiKey', 'domain', 'component', 'default'); - let result = await switcher.isItOn('FF2FOR2030').then(function (result) { - assert.isUndefined(result) + await switcher.isItOn('FF2FOR2030').then(function (result) { + assert.isUndefined(result); }, function (error) { - assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message) - }) - }) + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); + + }); + +}); +describe('E2E test - Switcher offline - Snapshot:', function () { + let switcher; + const apiKey = '$2b$08$S2Wj/wG/Rfs3ij0xFbtgveDtyUAjML1/TOOhocDg5dhOaU73CEXfK'; + const domain = 'currency-api'; + const component = 'Android'; + const environment = 'dev'; + const url = 'http://localhost:3000'; + + const dataBuffer = fs.readFileSync('./snapshot/dev.json'); + const dataJSON = dataBuffer.toString(); + + let requestGetStub; + let requestPostStub; + let clientAuth; + let fsStub; + + afterEach(function() { + requestGetStub.restore(); + requestPostStub.restore(); + clientAuth.restore(); + fsStub.restore(); }) -}) \ No newline at end of file + beforeEach(function() { + switcher = new Switcher(url, apiKey, domain, component, environment, { + offline: true + }); + + clientAuth = sinon.stub(services, 'auth'); + requestGetStub = sinon.stub(request, 'get'); + requestPostStub = sinon.stub(request, 'post'); + fsStub = sinon.stub(fs, 'writeFileSync'); + }); + + this.afterAll(function() { + switcher.unloadSnapshot(); + }); + + it('should update snapshot', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: false })); // Snapshot outdated + requestPostStub.returns(Promise.resolve(JSON.stringify(JSON.parse(dataJSON), null, 4))); + // Mocking finishes + + assert.isTrue(await switcher.checkSnapshot()); + }); + + it('should NOT update snapshot', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: true })); // No available update + // Mocking finishes + + assert.isFalse(await switcher.checkSnapshot()); + }); + + it('should NOT update snapshot - check Snapshot Error', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.throws({ + error: { + errno: 'ECONNREFUSED', + code: 'ECONNREFUSED', + syscall: 'connect' + } + }); + // Mocking finishes + + await switcher.checkSnapshot().then(function (result) { + assert.isUndefined(result); + }, function (error) { + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); + + it('should NOT update snapshot - resolve Snapshot Error', async function () { + // Mocking starts + clientAuth.returns(Promise.resolve({ token: 'uqwu1u8qj18j28wj28', exp: (Date.now()+5000)/1000 })); + requestGetStub.returns(Promise.resolve({ status: false })); // Snapshot outdated + requestPostStub.throws({ + error: { + errno: 'ECONNREFUSED', + code: 'ECONNREFUSED', + syscall: 'connect' + } + }); + // Mocking finishes + + await switcher.checkSnapshot().then(function (result) { + assert.isUndefined(result); + }, function (error) { + assert.equal('Something went wrong: {"errno":"ECONNREFUSED","code":"ECONNREFUSED","syscall":"connect"}', error.message); + }); + }); + +}); \ No newline at end of file