Search code examples
javascriptnode.jsamazon-web-servicesaws-sdk-mock

Mock Javascript AWS.RDS.Signer


I have Connection class that is used to connect to AWS Rds Proxy via IAM Authentication. Part of that process is to create a token. I have a function to create the token but now I having a hard time to mock and test it.

Here is the Connection class with setToken method:

class Connection {
    constructor(username, endpoint, database) {
        this.username = username;
        this.endpoint = endpoint;
        this.database = database;
    }

    setToken () {
        let signer = new AWS.RDS.Signer({
            region: 'us-east-1', // example: us-east-2
            hostname: this.endpoint,
            port: 3306,
            username: this.username
        });

        this.token = signer.getAuthToken({
            username: this.username
        });
    }
}

And here I am trying to mock the return value of AWS.RDS.Signer.getAuthToken()

test('Test Connection setToken', async () => {
    AWSMock.setSDKInstance(AWS);
    AWSMock.mock('RDS.Signer', 'getAuthToken', 'mock-token');


    let conn = new connections.Connection(
        'testUser',
        'testEndpoint',
        'testDb');

    conn.setToken();

    console.log(conn.token);
});

I expected to see "mock-token" as the value for conn.token, but what I get is this:

{
  promise: [Function],
  createReadStream: [Function: createReadStream],
  on: [Function: on],
  send: [Function: send]
}

How can I get AWS.RDS.Signer.getAuthToken() to return a mock token?


Edit after trying solution from @ggordon

I have tried to get this to work by injecting AWS into the constructor, but still seem to be having the same issue. I think part of my problem is that AWS.RDS.Signer does not support promises, but I'm not entirely sure.

Here is my new code:

The Token class which generates the token. import AWS from 'aws-sdk';

class Token {
    constructor(awsInstance) {
        this.awsInstance = awsInstance || AWS;
    }

    getToken () {
        const endpoint = 'aurora-proxy.proxy.rds.amazonaws.com';

        const signer = new this.awsInstance.RDS.Signer({
            region: 'my-region',
            hostname: endpoint,
            port: 3306,
            username: 'myUser'
        });

        const token = signer.getAuthToken({
                username: 'svcLambda'
            });

        console.log ("IAM Token obtained\n");
        return token
    }
}

module.exports = { Token };

And the test:

test('Should test getToken from Token', async () => {
    AWSMock.setSDKInstance(AWS);
    AWSMock.mock('RDS.Signer', 'getAuthToken', 'mock-token');

    let tokenObject = new tokens.Token(AWS);
    const token = tokenObject.getToken();

    console.log(token);
    expect(token).toStrictEqual('mock-token');
});

The Token class itself works -- it creates the token and the token can be used to make a successful connection to RDS. However, the unit test fails with the actual token returned (from console.log) being this:

{
  promise: [Function],
  createReadStream: [Function: createReadStream],
  on: [Function: on],
  send: [Function: send]
}

Also here is the package.json as requested by @GSSWain

{
  "name": "mylambda",
  "version": "0.0.1",
  "description": "My description.",
  "repository": {
    "type": "git",
    "url": ""
  },
  "scripts": {
    "lint": "eslint src/**/*.js __tests__/**/*.js",
    "prettier": "prettier --write src/**/*.js __tests__/**/*.js",
    "prettier:ci": "prettier --list-different src/**/*.js  __tests__/**/*.js",
    "test": "cross-env NODE_ENV=test jest",
    "test:coverage": "cross-env CI=true jest --coverage --watchAll=false -u --reporter=default --reporters=jest-junit",
    "build": "npm run build:dev",
    "build:dev": "cross-env NODE_ENV=development webpack --config webpack.config.js"
  },
  "dependencies": {
    "mysql2": "^2.2.5"
  },
  "devDependencies": {
    "@babel/core": "^7.6.4",
    "@babel/preset-env": "^7.6.3",
    "aws-sdk": "^2.552.0",
    "aws-sdk-mock": "^5.1.0",
    "babel-jest": "^24.9.0",
    "babel-loader": "^8.0.6",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "cross-env": "^6.0.3",
    "eslint": "^6.5.1",
    "eslint-config-prettier": "^6.4.0",
    "eslint-plugin-jest": "^22.19.0",
    "jest": "^24.9.0",
    "jest-junit": "^10.0.0",
    "prettier": "^1.18.2",
    "sinon": "^9.0.3"
  },
  "jest": {
    "verbose": true,
    "transform": {
      "^.+\\.js$": "babel-jest"
    },
    "globals": {
      "NODE_ENV": "test"
    },
    "moduleFileExtensions": [
      "js"
    ],
    "moduleDirectories": [
      "node_modules",
      "src"
    ],
    "coverageThreshold": {
      "global": {
        "statements": 100,
        "branches": 100,
        "functions": 100,
        "lines": 100
      }
    }
  },
  "jest-junit": {
    "outputName": "junit_jest.xml"
  }
}

Solution

  • Problem

    The AWS instance/object in your test scope is different from the AWS instance/object being used in your setToken method.

    aws-sdk-mock mocks this instance

    Due to transpiling, code written in TypeScript or ES6 may not correctly mock because the aws-sdk object created within aws-sdk-mock will not be equal to the object created within the code to test.

    Also require will return a new instance.

    In essence you are mocking an instance in your test while your actual code is using another instance that has not been mocked.

    Possible Solutions

    Solution 1

    You could modify your code to allow you to optionally inject the desired AWS instances to use eg

    import AWS from 'aws-sdk';
    class Connection {
        constructor(username, endpoint, database,awsInstance) {
            this.username = username;
            this.endpoint = endpoint;
            this.database = database;
            //if the awsInstance is null  or not provided use the default
            this.awsInstance = awsInstance || AWS;
        }
    
        setToken () {
            let signer = new this.awsInstance.RDS.Signer({
                region: 'us-east-1', // example: us-east-2
                hostname: this.endpoint,
                port: 3306,
                username: this.username
            });
    
            this.token = signer.getAuthToken({
                username: this.username
            });
        }
    }
    

    your code would not need any modifications, however now you can optionally in your tests

    test('Test Connection setToken', async () => {
        AWSMock.setSDKInstance(AWS);
        AWSMock.mock('RDS.Signer', 'getAuthToken', 'mock-token');
    
    
        let conn = new connections.Connection(
            'testUser',
            'testEndpoint',
            'testDb',
            AWS //pass mock instance
            );
    
        conn.setToken();
        let actualToken = (await conn.token.promise());
    
        console.log(conn.token);
        console.log(actualToken);
    });
    

    This is only constructor based injection, you could inject it by doing similar in the setToken method.

    You will also notice that in the examples provided by aws-sdk-mock and the example above we've extracted the result from the promise object returned. This is because the mock implementation returns a promise object despite the fact that the aws-sdk especially for the AWS.RDS.Signer.getAuthToken supports synchronous operations. This is a constraint based on the library you are using.

    Solution 2

    You may want to consider another mocking library if you are interested in synchronous calls which based on the examples shared here would better mimic your code/flow. The other alternative is to consider an asynchronous/promise rewrite of your implementations. I leave this decision to you.

    A simple alternative could be:

    test('Test Connection setToken', async () => {
        AWS.RDS.Signer = function MockSigner() {
            return {
                getAuthToken: function MockGetAuthToken(){ return 'mock-token'; }
            };
        };
    
    
        let conn = new connections.Connection(
            'testUser',
            'testEndpoint',
            'testDb',
            AWS //pass mock instance
            );
    
        conn.setToken();
    
        console.log(conn.token);
    });
    

    Additional References

    I've included a snippet of the method used to mock functions for the aws-sdk-mock retrieved from https://github.com/dwyl/aws-sdk-mock/blob/master/index.js#L118 . You'll see that it creates and returns a request

    function mockServiceMethod(service, client, method, replace) {
      services[service].methodMocks[method].stub = sinon.stub(client, method).callsFake(function() {
        const args = Array.prototype.slice.call(arguments);
    
        let userArgs, userCallback;
        if (typeof args[(args.length || 1) - 1] === 'function') {
          userArgs = args.slice(0, -1);
          userCallback = args[(args.length || 1) - 1];
        } else {
          userArgs = args;
        }
        const havePromises = typeof AWS.Promise === 'function';
        let promise, resolve, reject, storedResult;
        const tryResolveFromStored = function() {
          if (storedResult && promise) {
            if (typeof storedResult.then === 'function') {
              storedResult.then(resolve, reject)
            } else if (storedResult.reject) {
              reject(storedResult.reject);
            } else {
              resolve(storedResult.resolve);
            }
          }
        };
        const callback = function(err, data) {
          if (!storedResult) {
            if (err) {
              storedResult = {reject: err};
            } else {
              storedResult = {resolve: data};
            }
          }
          if (userCallback) {
            userCallback(err, data);
          }
          tryResolveFromStored();
        };
        const request = {
          promise: havePromises ? function() {
            if (!promise) {
              promise = new AWS.Promise(function (resolve_, reject_) {
                resolve = resolve_;
                reject = reject_;
              });
            }
            tryResolveFromStored();
            return promise;
          } : undefined,
          createReadStream: function() {
            if (replace instanceof Readable) {
              return replace;
            } else {
              const stream = new Readable();
              stream._read = function(size) {
                if (typeof replace === 'string' || Buffer.isBuffer(replace)) {
                  this.push(replace);
                }
                this.push(null);
              };
              return stream;
            }
          },
          on: function(eventName, callback) {
          },
          send: function(callback) {
          }
        };
    
        // different locations for the paramValidation property
        const config = (client.config || client.options || _AWS.config);
        if (config.paramValidation) {
          try {
            // different strategies to find method, depending on wether the service is nested/unnested
            const inputRules =
              ((client.api && client.api.operations[method]) || client[method] || {}).input;
            if (inputRules) {
              const params = userArgs[(userArgs.length || 1) - 1];
              new _AWS.ParamValidator((client.config || _AWS.config).paramValidation).validate(inputRules, params);
            }
          } catch (e) {
            callback(e, null);
            return request;
          }
        }
    
        // If the value of 'replace' is a function we call it with the arguments.
        if (typeof replace === 'function') {
          const result = replace.apply(replace, userArgs.concat([callback]));
          if (storedResult === undefined && result != null &&
              typeof result.then === 'function') {
            storedResult = result
          }
        }
        // Else we call the callback with the value of 'replace'.
        else {
          callback(null, replace);
        }
        return request;
      });
    }