diff --git a/.circleci/config.yml b/.circleci/config.yml index 517efef..100dee9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -6,7 +6,6 @@ workflows: jobs: - oldest-long-term-support-release - current-release - - audit-dependencies node-template: &node-template steps: @@ -35,10 +34,3 @@ jobs: docker: - image: circleci/node:latest - image: redis - - audit-dependencies: - docker: - - image: circleci/node:latest - steps: - - checkout - - run: npm audit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9a779e..7e6d025 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,42 @@ -Contributing to the LaunchDarkly SDK for Node.js -================================================ +# Contributing to the LaunchDarkly Server-Side SDK for Node.js -We encourage pull-requests and other contributions from the community. We've also published an [SDK contributor's guide](http://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. \ No newline at end of file +LaunchDarkly has published an [SDK contributor's guide](https://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. See below for additional information on how to contribute to this SDK. + +## Submitting bug reports and feature requests + +The LaunchDarkly SDK team monitors the [issue tracker](https://github.com/launchdarkly/node-server-sdk/issues) in the SDK repository. Bug reports and feature requests specific to this SDK should be filed in this issue tracker. The SDK team will respond to all newly filed issues within two business days. + +## Submitting pull requests + +We encourage pull requests and other contributions from the community. Before submitting pull requests, ensure that all temporary or unintended code is removed. Don't worry about adding reviewers to the pull request; the LaunchDarkly SDK team will add themselves. The SDK team will acknowledge all pull requests within two business days. + +## Build instructions + +### Prerequisites + +The project uses `npm`, which is bundled in all supported versions of Node. + +### Building + +To build, from the project root directory: + +``` +npm install +npm run build +``` + +### Testing + +To run all unit tests: + +``` +npm test +``` + +By default, the full unit test suite includes live tests of the Redis integration. Those tests expect you to have a Redis instance running locally. To skip them, set the environment variable `LD_SKIP_DATABASE_TESTS=1` before running the tests. + +To verify that the TypeScript declarations compile correctly (this involves compiling the file `test-types.ts`, so if you have changed any types or interfaces, you will want to update that code): + +``` +npm run check-typescript +``` diff --git a/README.md b/README.md index 4b83453..901f4b8 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,46 @@ -LaunchDarkly SDK for Node.js -=========================== +# LaunchDarkly Server-Side SDK for Node.js -[![Circle CI](https://circleci.com/gh/launchdarkly/node-client/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/node-client/tree/master) +[![Circle CI](https://circleci.com/gh/launchdarkly/node-server-sdk/tree/master.svg?style=svg)](https://circleci.com/gh/launchdarkly/node-server-sdk/tree/master) -Supported Node versions ------------------------ +## LaunchDarkly overview -This version of the LaunchDarkly SDK has been tested with Node versions 6.14 and up. - -Quick setup ------------ - -0. Install the Node.js SDK with `npm` - - npm install ldclient-node --save - -1. Require the LaunchDarkly client: - - var LaunchDarkly = require('ldclient-node'); - - -2. Create a new LDClient with your SDK key: - - var ld_client = LaunchDarkly.init("YOUR SDK KEY") +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/docs/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) -HTTPS proxy ------------- +## Supported Node versions -Node provides built-in support for the use of an HTTPS proxy. You can use NPM to configure Node to proxy all network requests through the URL provided. -``` -npm config set https-proxy https://web-proxy.domain.com:8080 -``` - - -If your proxy requires authentication then you can prefix the URN with your login information: -``` -npm config set https-proxy http://user:pass@web-proxy.domain.com:8080 -``` - - -Your first feature flag ------------------------ - -1. Create a new feature flag on your [dashboard](https://app.launchdarkly.com) -2. In your application code, use the feature's key to check whether the flag is on for each user: - - ld_client.once('ready', function() { - ld_client.variation("your.flag.key", {"key" : "user@test.com"}, false, function(err, show_feature) { - if (show_feature) { - # application code to show the feature - } else { - # the code to run if the feature is off - } - }); - }); +This version of the LaunchDarkly SDK has been tested with Node versions 6.14 and up. -Using flag data from a file ---------------------------- +## Getting started -For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See `FileDataSource` in the [TypeScript API documentation](https://github.com/launchdarkly/node-client/blob/master/index.d.ts) for more details. +Refer to the [SDK reference guide](https://docs.launchdarkly.com/docs/node-sdk-reference) for instructions on getting started with using the SDK. -Learn more ------------ +## Learn more Check out our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](http://docs.launchdarkly.com/docs/node-sdk-reference). -Testing -------- +The authoritative description of all properties and methods is in the [TypeScript documentation](https://launchdarkly.github.io/node-server-sdk/). + +## Testing We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. -Contributing ------------- +## Contributing -We encourage pull-requests and other contributions from the community. We've also published an [SDK contributor's guide](http://docs.launchdarkly.com/docs/sdk-contributors-guide) that provides a detailed explanation of how our SDKs work. +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. -About LaunchDarkly ------------ +## About LaunchDarkly * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. -* LaunchDarkly provides feature flag SDKs for - * [Java](http://docs.launchdarkly.com/docs/java-sdk-reference "Java SDK") - * [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK") - * [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK") - * [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK") - * [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK") - * [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK") - * [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK") - * [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK") - * [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK") - * [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK") - * [Android](http://docs.launchdarkly.com/docs/android-sdk-reference "LaunchDarkly Android SDK") +* LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/docs) for a complete list. * Explore LaunchDarkly - * [launchdarkly.com](http://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information - * [docs.launchdarkly.com](http://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDKs - * [apidocs.launchdarkly.com](http://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation - * [blog.launchdarkly.com](http://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates + * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information + * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides + * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation + * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates * [Feature Flagging Guide](https://github.com/launchdarkly/featureflags/ "Feature Flagging Guide") for best practices and strategies diff --git a/docs/typedoc.js b/docs/typedoc.js index c8c45ed..c04dd31 100644 --- a/docs/typedoc.js +++ b/docs/typedoc.js @@ -3,13 +3,19 @@ // the properties are equivalent to the command-line options described here: // https://typedoc.org/api/ +let version = process.env.VERSION; +if (!version) { + const package = require('../package.json'); + version = package.version; +} + module.exports = { out: './docs/build/html', exclude: [ '**/node_modules/**', 'test-types.ts' ], - name: 'LaunchDarkly Node SDK', + name: 'LaunchDarkly Server-Side Node SDK (' + version + ')', readme: 'none', // don't add a home page with a copy of README.md mode: 'file', // don't treat "index.d.ts" itself as a parent module includeDeclarations: true, // allows it to process a .d.ts file instead of actual TS code diff --git a/event_processor.js b/event_processor.js index 051ffb2..a53a4d5 100644 --- a/event_processor.js +++ b/event_processor.js @@ -196,8 +196,8 @@ function EventProcessor(sdkKey, config, errorReporter) { } } - request({ - method: "POST", + var options = Object.assign({}, config.tlsParams, { + method: 'POST', url: config.eventsUri + '/bulk', headers: { 'Authorization': sdkKey, @@ -208,7 +208,8 @@ function EventProcessor(sdkKey, config, errorReporter) { body: events, timeout: config.timeout * 1000, agent: config.proxyAgent - }).on('response', function(resp, body) { + }); + request(options).on('response', function(resp, body) { if (resp.headers['date']) { var date = Date.parse(resp.headers['date']); if (date) { diff --git a/eventsource.js b/eventsource.js index 4cee815..42c6b92 100644 --- a/eventsource.js +++ b/eventsource.js @@ -90,6 +90,9 @@ function EventSource(url, eventSourceInitDict) { } options.rejectUnauthorized = !(eventSourceInitDict && eventSourceInitDict.rejectUnauthorized == false); + if (eventSourceInitDict && eventSourceInitDict.tlsParams) { + Object.assign(options, eventSourceInitDict.tlsParams); // these options are ignored if we're not using HTTPS + } req = (isSecure ? https : http).request(options, function (res) { // Handle HTTP redirects diff --git a/feature_store_event_wrapper.js b/feature_store_event_wrapper.js index ae2850d..f081133 100644 --- a/feature_store_event_wrapper.js +++ b/feature_store_event_wrapper.js @@ -19,7 +19,7 @@ function FeatureStoreEventWrapper(featureStore, emitter) { featureStore.all(dataKind.features, function(oldFlags){ featureStore.init(newData, function(){ var allFlags = {}; - var newFlags = newData[dataKind.features] || {}; + var newFlags = newData[dataKind.features.namespace] || {}; Object.assign(allFlags, oldFlags, newFlags); var handledFlags = {}; diff --git a/index.d.ts b/index.d.ts index 197d974..d425214 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,7 +1,7 @@ // Type definitions for ldclient-node /** - * This is the API reference for the LaunchDarkly SDK for Node.js. + * This is the API reference for the LaunchDarkly Server-Side SDK for Node.js. * * In typical usage, you will call [[init]] once at startup time to obtain an instance of * [[LDClient]], which provides access to all of the SDK's functionality. @@ -344,6 +344,33 @@ declare module 'ldclient-node' { * Defaults to 300. */ userKeysFlushInterval?: number; + + /** + * Additional parameters to pass to the Node HTTPS API for secure requests. These can include any + * of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`. + * + * For more information, see the Node documentation for `https.request()` and `tls.connect()`. + */ + tlsParams?: LDTLSOptions; + } + + /** + * Additional parameters to pass to the Node HTTPS API for secure requests. These can include any + * of the TLS-related parameters supported by `https.request()`, such as `ca`, `cert`, and `key`. + * + * For more information, see the Node documentation for `https.request()` and `tls.connect()`. + */ + export interface LDTLSOptions { + ca?: string | string[] | Buffer | Buffer[]; + cert?: string | string[] | Buffer | Buffer[]; + checkServerIdentity?: (servername: string, cert: any) => Error | undefined; + ciphers?: string; + pfx?: string | string[] | Buffer | Buffer[] | object[]; + key?: string | string[] | Buffer | Buffer[] | object[]; + passphrase?: string; + rejectUnauthorized?: boolean; + secureProtocol?: string; + servername?: string; } /** diff --git a/package-lock.json b/package-lock.json index f827b2c..9f1c548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ldclient-node", - "version": "5.7.2", + "version": "5.7.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4789,6 +4789,12 @@ "lodash": "4.x" } }, + "node-forge": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.7.5.tgz", + "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==", + "dev": true + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5685,6 +5691,15 @@ "integrity": "sha1-KBYjTiN4vdxOU1T6tcqold9xANk=", "dev": true }, + "selfsigned": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.4.tgz", + "integrity": "sha512-9AukTiDmHXGXWtWjembZ5NDmVvP2695EtpgbCsxCa68w3c88B+alqbmZ4O3hZ4VWGXeGWzEVdvqgAJD8DQPCDw==", + "dev": true, + "requires": { + "node-forge": "0.7.5" + } + }, "semver": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", diff --git a/package.json b/package.json index ef3cf44..aa9df2e 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "jest": "24.5.0", "jest-junit": "3.6.0", "nock": "9.2.3", + "selfsigned": "1.10.4", "tmp": "0.0.33", "typedoc": "0.14.2", "typescript": "3.0.1" diff --git a/polling.js b/polling.js index 5eb9a3c..009d374 100644 --- a/polling.js +++ b/polling.js @@ -19,11 +19,12 @@ function PollingProcessor(config, requestor) { startTime = new Date().getTime(); config.logger.debug("Polling LaunchDarkly for feature flag updates"); requestor.requestAllData(function(err, resp) { - elapsed = new Date().getTime() - startTime; - sleepFor = Math.max(config.pollInterval * 1000 - elapsed, 0); + const elapsed = new Date().getTime() - startTime; + const sleepFor = Math.max(config.pollInterval * 1000 - elapsed, 0); config.logger.debug("Elapsed: %d ms, sleeping for %d ms", elapsed, sleepFor); if (err) { - cb(new errors.LDPollingError(messages.httpErrorMessage(err.status, 'polling request', 'will retry'))); + const message = err.status || err.message; + cb(new errors.LDPollingError(messages.httpErrorMessage(message, 'polling request', 'will retry'))); if (!errors.isHttpErrorRecoverable(err.status)) { config.logger.error('Received 401 error, no further polling requests will be made since SDK key is invalid'); } else { diff --git a/requestor.js b/requestor.js index 62e5e32..f2ae94b 100644 --- a/requestor.js +++ b/requestor.js @@ -23,7 +23,7 @@ function Requestor(sdkKey, config) { var requestWithETagCaching = new ETagRequest(cacheConfig); function makeRequest(resource) { - var requestParams = { + var requestParams = Object.assign({}, config.tlsParams, { method: "GET", url: config.baseUri + resource, headers: { @@ -32,7 +32,7 @@ function Requestor(sdkKey, config) { }, timeout: config.timeout * 1000, agent: config.proxyAgent - } + }); return function(cb, errCb) { requestWithETagCaching(requestParams, function(err, resp, body) { diff --git a/scripts/release-docs.sh b/scripts/release-docs.sh new file mode 100755 index 0000000..55450c6 --- /dev/null +++ b/scripts/release-docs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# This script generates HTML documentation for the current release and publishes it to the +# "gh-pages" branch of the current repository. The current repository should be the public one, +# and it must already have a "gh-pages" branch. + +# It takes exactly one argument: the new version. +# It should be run from the root of this git repo like this: +# ./scripts/release.sh 4.0.9 + +# The "docs" directory must contain a Makefile that will generate docs into "docs/build/html". +# It will receive the version string in the environment variable $VERSION (in case it is not +# easy for the documentation script to read the version directly from the project). + +set -uxe +echo "Building and releasing documentation." + +export VERSION=$1 + +PROJECT_DIR=$(pwd) +GIT_URL=$(git remote get-url origin) + +TEMP_DIR=$(mktemp -d /tmp/sdk-docs.XXXXXXX) +DOCS_CHECKOUT_DIR=$TEMP_DIR/checkout + +git clone -b gh-pages $GIT_URL $DOCS_CHECKOUT_DIR + +cd $PROJECT_DIR/docs +make + +cd $DOCS_CHECKOUT_DIR + +git rm -r * || true +touch .nojekyll # this turns off unneeded preprocessing by GH Pages which can break our docs +git add .nojekyll +cp -r $PROJECT_DIR/docs/build/html/* . +git add * +git commit -m "Updating documentation to version $VERSION" +git push origin gh-pages + +cd $PROJECT_DIR +rm -rf $TEMP_DIR diff --git a/scripts/release.sh b/scripts/release.sh index 063b699..f607412 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# This script publishes a new version of the ldclient-node SDK to NPM. It also updates the version in package.json. +# This script publishes a new version of the SDK to NPM. It also updates the version in package.json. # It takes exactly one argument: the new version. # It should be run from the root of this git repo like this: @@ -8,7 +8,7 @@ # When done you should commit and push the changes made. set -uxe -echo "Starting node-client release." +echo "Starting node-server-sdk release." VERSION=$1 npm --version @@ -17,10 +17,18 @@ npm --version # We're intentionally not running 'npm version' because it does a git commit, which interferes # with other parts of this automated release process. -# Update version in setup.py PACKAGE_JSON_TEMP=./package.json.tmp sed "s/\"version\".*/\"version\": \"${VERSION}\",/g" package.json > ${PACKAGE_JSON_TEMP} mv ${PACKAGE_JSON_TEMP} package.json + +npm install + npm publish -echo "Done with node-client release" +if [[ $VERSION =~ '-' ]]; then + echo "Not publishing documentation because this is not a production release" +else + ./scripts/release-docs.sh $VERSION +fi + +echo "Done with node-server-sdk release" diff --git a/streaming.js b/streaming.js index 4f53488..7301ff0 100644 --- a/streaming.js +++ b/streaming.js @@ -19,7 +19,8 @@ function StreamProcessor(sdkKey, config, requestor, eventSourceFactory) { es = new eventSourceFactory(config.streamUri + "/all", { agent: config.proxyAgent, - headers: {'Authorization': sdkKey,'User-Agent': config.userAgent} + headers: {'Authorization': sdkKey,'User-Agent': config.userAgent}, + tlsParams: config.tlsParams }); es.onerror = function(err) { diff --git a/test-types.ts b/test-types.ts index e482933..54ae405 100644 --- a/test-types.ts +++ b/test-types.ts @@ -25,7 +25,12 @@ var allOptions: ld.LDOptions = { userKeysFlushInterval: 1, pollInterval: 5, timeout: 1, - logger: logger + logger: logger, + tlsParams: { + ca: 'x', + cert: 'y', + key: 'z' + } }; var userWithKeyOnly: ld.LDUser = { key: 'user' }; var user: ld.LDUser = { diff --git a/test/LDClient-tls-test.js b/test/LDClient-tls-test.js new file mode 100644 index 0000000..ec03248 --- /dev/null +++ b/test/LDClient-tls-test.js @@ -0,0 +1,122 @@ +import * as selfsigned from 'selfsigned'; + +import * as LDClient from '../index'; +import * as httpServer from './http_server'; +import * as stubs from './stubs'; + +describe('LDClient TLS configuration', () => { + const sdkKey = 'secret'; + let logger = stubs.stubLogger(); + let server; + let certData; + + beforeEach(async () => { + certData = await makeSelfSignedPems(); + const serverOptions = { key: certData.private, cert: certData.cert, ca: certData.public }; + server = await httpServer.createServer(true, serverOptions); + }); + + afterEach(() => { + httpServer.closeServers(); + }); + + async function makeSelfSignedPems() { + const certAttrs = [{ name: 'commonName', value: 'localhost' }]; + const certOptions = { + // This part is based on code within the selfsigned package + extensions: [ + { + name: 'subjectAltName', + altNames: [{ type: 6, value: 'https://localhost' }], + }, + ], + }; + return await selfsigned.generate(certAttrs, certOptions); + } + + it('can connect via HTTPS to a server with a self-signed certificate, if CA is specified', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, {})); + const config = { + baseUri: server.url, + sendEvents: false, + stream: false, + logger: stubs.stubLogger(), + tlsParams: { ca: certData.cert }, + }; + const client = LDClient.init(sdkKey, config); + await client.waitForInitialization(); + client.close(); + }); + + it('cannot connect via HTTPS to a server with a self-signed certificate, using default config', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, {})); + const config = { + baseUri: server.url, + sendEvents: false, + stream: false, + logger: stubs.stubLogger(), + }; + const client = LDClient.init(sdkKey, config); + await expect(client.waitForInitialization()).rejects.toThrow(/self signed/); + }); + + it('can use custom TLS options for streaming as well as polling', async () => { + const eventData = { data: { flags: { flag: { version: 1 } }, segments: {} } }; + server.on('request', (req, res) => { + if (req.url.match(/\/stream/)) { + httpServer.respondSSEEvent(res, 'put', eventData); + } else { + httpServer.respondJson(res, {}); + } + }); + + const config = { + baseUri: server.url, + streamUri: server.url + '/stream', + sendEvents: false, + logger: logger, + tlsParams: { ca: certData.cert }, + }; + + const client = LDClient.init(sdkKey, config); + await client.waitForInitialization(); // this won't return until the stream receives the "put" event + client.close(); + }); + + it('can use custom TLS options for posting events', async () => { + let receivedEventFn; + const receivedEvent = new Promise(resolve => { + receivedEventFn = resolve; + }); + + server.on('request', (req, res) => { + if (req.url.match(/\/events/)) { + httpServer.readAll(req).then(body => { + receivedEventFn(body); + httpServer.respond(res, 200); + }); + } else { + httpServer.respondJson(res, {}); + } + }); + + const config = { + baseUri: server.url, + eventsUri: server.url + '/events', + stream: false, + logger: stubs.stubLogger(), + tlsParams: { ca: certData.cert }, + }; + + const client = LDClient.init(sdkKey, config); + await client.waitForInitialization(); + client.identify({ key: 'user' }); + await client.flush(); + + const receivedEventBody = await receivedEvent; + const eventData = JSON.parse(receivedEventBody); + expect(eventData.length).toEqual(1); + expect(eventData[0].kind).toEqual('identify'); + client.close(); + }); +}); diff --git a/test/file_data_source-test.js b/test/file_data_source-test.js index 5035daf..a343f60 100644 --- a/test/file_data_source-test.js +++ b/test/file_data_source-test.js @@ -217,7 +217,10 @@ describe('FileDataSource', function() { items = await asyncify(cb => store.all(dataKind.segments, cb)); expect(Object.keys(items).length).toEqual(1); - expect(logger.warn.mock.calls.length).toEqual(1); // we call logger.warn() once for each reload + // We call logger.warn() once for each reload. It should only have reloaded once, but for + // unknown reasons it occasionally fires twice in Windows. + expect(logger.warn.mock.calls.length).toBeGreaterThan(0); + expect(logger.warn.mock.calls.length).toBeLessThanOrEqual(2); }); it('evaluates simplified flag with client as expected', async () => { diff --git a/test/http_server.js b/test/http_server.js new file mode 100644 index 0000000..3047a05 --- /dev/null +++ b/test/http_server.js @@ -0,0 +1,77 @@ +import * as http from 'http'; +import * as https from 'https'; + +// This is adapted from some helper code in https://github.com/EventSource/eventsource/blob/master/test/eventsource_test.js + +let nextPort = 20000; +let servers = []; + +export function createServer(secure, options) { + const server = secure ? https.createServer(options) : http.createServer(options); + const port = nextPort++; + + server.requests = []; + const responses = []; + + server.on('request', (req, res) => { + server.requests.push(req); + responses.push(res); + }); + + const realClose = server.close; + server.close = callback => { + responses.forEach(res => res.end()); + realClose.call(server, callback); + }; + + server.url = (secure ? 'https' : 'http') + '://localhost:' + port; + + servers.push(server); + + return new Promise((resolve, reject) => { + server.listen(port, err => (err ? reject(err) : resolve(server))); + }); +} + +export function closeServers() { + servers.forEach(server => server.close()); + servers = []; +} + +export function readAll(req) { + return new Promise(resolve => { + let body = ''; + req.on('data', data => { + body += data; + }); + req.on('end', () => resolve(body)); + }); +} + +export function respond(res, status, headers, body, leaveOpen) { + res.writeHead(status, headers); + body && res.write(body); + if (!leaveOpen) { + res.end(); + } else { + res.write(':\n'); + } +} + +export function respondJson(res, data) { + respond(res, 200, { 'Content-Type': 'application/json' }, JSON.stringify(data)); +} + +export function respondSSEEvent(res, eventType, eventData) { + respond( + res, + 200, + { 'Content-Type': 'text/event-stream' }, + 'event: ' + eventType + '\ndata: ' + JSON.stringify(eventData) + '\n\n', + true, + ); +} + +export function autoRespond(server, respondFn) { + server.on('request', (req, res) => respondFn(res)); +} diff --git a/test/polling-test.js b/test/polling-test.js new file mode 100644 index 0000000..c918130 --- /dev/null +++ b/test/polling-test.js @@ -0,0 +1,141 @@ +const InMemoryFeatureStore = require('../feature_store'); +const PollingProcessor = require('../polling'); +const dataKind = require('../versioned_data_kind'); +const { asyncify, asyncifyNode, sleepAsync } = require('./async_utils'); + +describe('PollingProcessor', () => { + const longInterval = 100000; + const allData = { flags: { flag: { version: 1 } }, segments: { segment: { version: 1 } } }; + const jsonData = JSON.stringify(allData); + + let store; + let config; + let processor; + + beforeEach(() => { + store = InMemoryFeatureStore(); + config = { featureStore: store, pollInterval: longInterval, logger: fakeLogger() }; + }); + + afterEach(() => { + processor && processor.stop(); + }); + + function fakeLogger() { + return { + debug: jest.fn(), + error: jest.fn() + }; + } + + it('makes no request before start', () => { + const requestor = { + requestAllData: jest.fn() + }; + processor = PollingProcessor(config, requestor); + + expect(requestor.requestAllData).not.toHaveBeenCalled(); + }); + + it('polls immediately on start', () => { + const requestor = { + requestAllData: jest.fn() + }; + processor = PollingProcessor(config, requestor); + + processor.start(() => {}); + + expect(requestor.requestAllData).toHaveBeenCalledTimes(1); + }); + + it('calls callback on success', async () => { + const requestor = { + requestAllData: cb => cb(null, jsonData) + }; + processor = PollingProcessor(config, requestor); + + await asyncifyNode(cb => processor.start(cb)); // didn't throw -> success + }); + + it('calls callback with error on failure', async () => { + const err = new Error('sorry'); + const requestor = { + requestAllData: cb => cb(err) + }; + processor = PollingProcessor(config, requestor); + + await expect(asyncifyNode(cb => processor.start(cb))).rejects.toThrow(/sorry.*will retry/); + }); + + it('initializes feature store', async () => { + const requestor = { + requestAllData: cb => cb(null, jsonData) + }; + processor = PollingProcessor(config, requestor); + + await asyncifyNode(cb => processor.start(cb)); + + const flags = await asyncify(cb => store.all(dataKind.features, cb)); + expect(flags).toEqual(allData.flags); + const segments = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(segments).toEqual(allData.segments); + }); + + it('polls repeatedly', async() => { + const requestor = { + requestAllData: jest.fn(cb => cb(null, jsonData)) + }; + config.pollInterval = 0.1; // note, pollInterval is in seconds + processor = PollingProcessor(config, requestor); + + processor.start(() => {}); + await sleepAsync(500); + + expect(requestor.requestAllData.mock.calls.length).toBeGreaterThanOrEqual(4); + }); + + function testRecoverableHttpError(status) { + it('continues polling after error ' + status, async () => { + const err = new Error('sorry'); + err.status = status; + const requestor = { + requestAllData: jest.fn(cb => cb(err)) + }; + config.pollInterval = 0.1; + processor = PollingProcessor(config, requestor); + + processor.start(() => {}); + await sleepAsync(300); + + expect(requestor.requestAllData.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(config.logger.error).not.toHaveBeenCalled(); + }); + } + + testRecoverableHttpError(400); + testRecoverableHttpError(408); + testRecoverableHttpError(429); + testRecoverableHttpError(500); + testRecoverableHttpError(503); + + function testUnrecoverableHttpError(status) { + it('stops polling after error ' + status, async () => { + const err = new Error('sorry'); + err.status = status; + const requestor = { + requestAllData: jest.fn(cb => cb(err)) + }; + config.pollInterval = 0.1; + processor = PollingProcessor(config, requestor); + + processor.start(() => {}); + await sleepAsync(300); + + expect(requestor.requestAllData.mock.calls.length).toEqual(1); + expect(config.logger.error).toHaveBeenCalledTimes(1); + }); + } + + testUnrecoverableHttpError(401); + testUnrecoverableHttpError(403); +}); diff --git a/test/redis_feature_store-test.js b/test/redis_feature_store-test.js index 8c790a6..cfc92be 100644 --- a/test/redis_feature_store-test.js +++ b/test/redis_feature_store-test.js @@ -3,7 +3,10 @@ var testBase = require('./feature_store_test_base'); var dataKind = require('../versioned_data_kind'); var redis = require('redis'); -describe('RedisFeatureStore', function() { + +const shouldSkip = (process.env.LD_SKIP_DATABASE_TESTS === '1'); + +(shouldSkip ? describe.skip : describe)('RedisFeatureStore', function() { var redisOpts = { url: 'redis://localhost:6379' }; var extraRedisClient = redis.createClient(redisOpts); diff --git a/test/requestor-test.js b/test/requestor-test.js new file mode 100644 index 0000000..f913a2c --- /dev/null +++ b/test/requestor-test.js @@ -0,0 +1,93 @@ +import Requestor from '../requestor'; +import * as dataKind from '../versioned_data_kind'; +import { asyncifyNode } from './async_utils'; +import * as httpServer from './http_server'; + +describe('Requestor', () => { + const sdkKey = 'x'; + const badUri = 'http://bad-uri'; + const someData = { key: { version: 1 } }; + const allData = { flags: someData, segments: someData }; + + let server; + let config; + + beforeEach(async () => { + server = await httpServer.createServer(); + config = { baseUri: server.url }; + }); + + afterEach(() => { + httpServer.closeServers(); + }); + + describe('requestObject', () => { + it('uses correct flag URL', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, {})); + const r = Requestor(sdkKey, config); + await asyncifyNode(cb => r.requestObject(dataKind.features, 'key', cb)); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].url).toEqual('/sdk/latest-flags/key'); + }); + + it('uses correct segment URL', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, {})); + const r = Requestor(sdkKey, config); + await asyncifyNode(cb => r.requestObject(dataKind.segments, 'key', cb)); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].url).toEqual('/sdk/latest-segments/key'); + }); + + it('returns successful result', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, someData)); + const r = Requestor(sdkKey, config); + const result = await asyncifyNode(cb => r.requestObject(dataKind.features, 'key', cb)); + expect(JSON.parse(result)).toEqual(someData); + }); + + it('returns error result for HTTP error', async () => { + httpServer.autoRespond(server, res => httpServer.respond(res, 404)); + const r = Requestor(sdkKey, config); + const req = asyncifyNode(cb => r.requestObject(dataKind.features, 'key', cb)); + await expect(req).rejects.toThrow(/404/); + }); + + it('returns error result for network error', async () => { + config.baseUri = badUri; + const r = Requestor(sdkKey, config); + const req = asyncifyNode(cb => r.requestObject(dataKind.features, 'key', cb)); + await expect(req).rejects.toThrow(/bad-uri/); + }); + }); + + describe('requestAllData', () => { + it('uses correct URL', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, {})); + const r = Requestor(sdkKey, config); + await asyncifyNode(cb => r.requestAllData(cb)); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].url).toEqual('/sdk/latest-all'); + }); + + it('returns successful result', async () => { + httpServer.autoRespond(server, res => httpServer.respondJson(res, allData)); + const r = Requestor(sdkKey, config); + const result = await asyncifyNode(cb => r.requestAllData(cb)); + expect(JSON.parse(result)).toEqual(allData); + }); + + it('returns error result for HTTP error', async () => { + httpServer.autoRespond(server, res => httpServer.respond(res, 404)); + const r = Requestor(sdkKey, config); + const req = asyncifyNode(cb => r.requestAllData(cb)); + await expect(req).rejects.toThrow(/404/); + }); + + it('returns error result for network error', async () => { + config.baseUri = badUri; + const r = Requestor(sdkKey, config); + const req = asyncifyNode(cb => r.requestAllData(cb)); + await expect(req).rejects.toThrow(/bad-uri/); + }); + }); +}); diff --git a/test/streaming-test.js b/test/streaming-test.js index 1c0b47d..f07adfc 100644 --- a/test/streaming-test.js +++ b/test/streaming-test.js @@ -1,6 +1,6 @@ -var InMemoryFeatureStore = require('../feature_store'); -var StreamProcessor = require('../streaming'); -var dataKind = require('../versioned_data_kind'); +const InMemoryFeatureStore = require('../feature_store'); +const StreamProcessor = require('../streaming'); +const dataKind = require('../versioned_data_kind'); const { asyncify, sleepAsync } = require('./async_utils'); describe('StreamProcessor', function() {